Files
Agent-System/internal/server/server_test.go
weichenandCursor 7fdbee6c0c test(server): adapt fixtures to IAM dual-gate fail-closed
Boot memory IAM only via explicit test env opt-in; strict auth cases
disable AllowInMemory after startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 09:42:07 +00:00

464 lines
14 KiB
Go

package server
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/config"
"chat-agent-system/internal/stream"
)
func newTestServer(t testing.TB) *Server {
t.Helper()
cfg := &config.Config{
Env: "test",
AgentBackend: "stub",
DataDir: t.TempDir(),
KbDbPort: 5432,
AdminUsername: "admin",
AdminPassword: "admin-test-pass",
}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if mgr := srv.TaskRuns(); mgr != nil {
_ = mgr.WaitIdle(ctx)
}
_ = srv.Shutdown(ctx)
})
return srv
}
// newServerWithMemoryIAM 使用显式 dev/test + AllowInMemory 双门启动内存 IAM。
// 需要「严格请求鉴权」的用例可在返回后将 srv.cfg.AllowInMemory 设为 false。
func newServerWithMemoryIAM(t testing.TB, cfg *config.Config) *Server {
t.Helper()
if cfg == nil {
cfg = &config.Config{}
}
if cfg.DataDir == "" {
cfg.DataDir = t.TempDir()
}
if cfg.AgentBackend == "" {
cfg.AgentBackend = "stub"
}
cfg.AllowInMemory = true
env := strings.ToLower(strings.TrimSpace(cfg.Env))
if env == "" || env == "main" || env == "prod" {
cfg.Env = "test"
}
svc := bizdev.MustNewService(t, cfg)
return NewServer(cfg, svc)
}
func TestServer_Health(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp HealthResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if resp.Status != "ok" || resp.Env != "test" {
t.Errorf("unexpected health response: %+v", resp)
}
}
func TestServer_ChatBlocking(t *testing.T) {
srv := newTestServer(t)
chatReq := ChatRequest{
SessionID: "test_sess_01",
Message: "查一下当前进展",
ProjectName: "内窥镜研发项目",
CustomerName: "水木东方",
}
body, _ := json.Marshal(chatReq)
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(body))
withBearerAuth(req, loginTestAdmin(t, srv))
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp chat.ChatResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode chat response failed: %v", err)
}
if resp.ReplyText == "" {
t.Errorf("expected non-empty reply text")
}
}
func TestServer_ChatDocumentSummaryReadsExistingDocumentWithoutWriteAction(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
workDir := t.TempDir()
markdown := []byte("# 用户初始需求收集表\n\n## 核心需求\n确认临床使用场景。\n\n## 待确认\n目标市场尚待确认。\n")
if err := os.WriteFile(filepath.Join(workDir, "output.md"), markdown, 0o644); err != nil {
t.Fatalf("write output.md: %v", err)
}
if _, err := srv.gov.CreateOrGetDocument(
ctx, "doc_proj143_intake", 143, "intake", "用户初始需求收集表", "V0.1_triage", workDir,
1, "编制人", 1, "审核人", 1, "批准人",
); err != nil {
t.Fatalf("create document: %v", err)
}
hash := fmt.Sprintf("%x", sha256.Sum256(markdown))
if err := srv.gov.MarkDocumentContentUpdated(ctx, "doc_proj143_intake", hash, false); err != nil {
t.Fatalf("mark content: %v", err)
}
body, _ := json.Marshal(ChatRequest{
SessionID: "fixture-doc-summary",
Message: "总结一下用户初始需求表",
ProjectID: 143,
ProjectName: "隔离验收项目",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(body))
withBearerAuth(req, loginTestAdmin(t, srv))
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp chat.ChatResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.IntentResult == nil || resp.IntentResult.TopIntent == nil || resp.IntentResult.TopIntent.Intent != "QUERY_DOC_STATUS" {
t.Fatalf("unexpected intent: %+v", resp.IntentResult)
}
if got := resp.IntentResult.TopIntent.FilledSlots["query_topic"]; got != "doc_summary" {
t.Fatalf("query_topic=%q", got)
}
if resp.CoordinationResult == nil || resp.CoordinationResult.PendingAction != nil {
t.Fatalf("summary must not expose a write action: %+v", resp.CoordinationResult)
}
if !strings.Contains(resp.ReplyText, "目标市场") || !strings.Contains(resp.ReplyText, "用户初始需求收集表") {
t.Fatalf("reply does not summarize existing content: %s", resp.ReplyText)
}
}
func TestServer_ChatSSEStream(t *testing.T) {
srv := newTestServer(t)
chatReq := StreamChatRequest{
SessionID: "test_sess_sse",
Message: "查一下当前进展",
IncludeDetails: false,
ProjectName: "内窥镜研发项目",
CustomerName: "水木东方",
}
body, _ := json.Marshal(chatReq)
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(body))
withBearerAuth(req, loginTestAdmin(t, srv))
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/event-stream") {
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
}
// 解析 SSE 事件流
scanner := bufio.NewScanner(w.Body)
var currentEvent string
var eventsReceived []string
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
currentEvent = strings.TrimPrefix(line, "event: ")
} else if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
var ev stream.StreamEvent
if err := json.Unmarshal([]byte(dataStr), &ev); err == nil {
eventsReceived = append(eventsReceived, currentEvent)
}
}
}
if len(eventsReceived) == 0 {
t.Fatalf("expected to receive SSE events, got 0")
}
hasPhase := false
hasStatus := false
hasDone := false
for _, et := range eventsReceived {
switch et {
case "phase":
hasPhase = true
case "status":
hasStatus = true
case "done":
hasDone = true
}
}
if !hasPhase {
t.Errorf("expected phase event in SSE stream")
}
if !hasStatus {
t.Errorf("expected status event in SSE stream")
}
if !hasDone {
t.Errorf("expected done event in SSE stream")
}
}
func TestServer_ContextAndDocGovAPIs(t *testing.T) {
srv := newTestServer(t)
token := loginTestAdmin(t, srv)
actorID := int64(1)
// 1. 测试更新并获取用户偏好
prefReq := UpdateUserPreferenceRequest{
UserID: 101,
CommunicationStyle: "concise_structured",
FocusAreas: []string{"cost_budget", "reg_compliance"},
OutputFormatPref: "standard_report",
CustomInstructions: "结论先行,重点突出NMPA二类申报风险",
}
prefBytes, _ := json.Marshal(prefReq)
req := httptest.NewRequest(http.MethodPut, "/api/v1/context/user/preference", bytes.NewReader(prefBytes))
withBearerAuth(req, token)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from update user preference, got %d", w.Code)
}
// 2. 测试获取单用户上下文
req = httptest.NewRequest(http.MethodGet, "/api/v1/context/user?user_id=101&project_id=1", nil)
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from get user context, got %d", w.Code)
}
// 3. 测试获取项目事实黑板上下文
req = httptest.NewRequest(http.MethodGet, "/api/v1/context/project?project_id=1", nil)
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from get project context, got %d", w.Code)
}
// 4. 测试任务运行与文档生成
taskReq := TaskRunRequestDTO{
TaskKey: "feasibility_reg",
ProjectID: 1,
ProjectName: "超声骨刀系统",
CustomerName: "北京水木东方",
DeviceType: "超声手术设备",
UserID: actorID,
AuthorID: actorID,
AuthorName: "张工",
ReviewerID: actorID,
ReviewerName: "李主管",
ApproverID: actorID,
ApproverName: "王总监",
}
taskBytes, _ := json.Marshal(taskReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run", bytes.NewReader(taskBytes))
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from tasks/run, got %d: %s", w.Code, w.Body.String())
}
var taskRes bizdev.TaskRunResult
if err := json.NewDecoder(w.Body).Decode(&taskRes); err != nil {
t.Fatalf("decode task run result: %v", err)
}
docID := "doc_proj1_feasibility_reg"
if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" {
docID = taskRes.DocRecord.DocID
}
// 5. 查询生成的受控文档
req = httptest.NewRequest(http.MethodGet, "/api/v1/docgov/document?doc_id="+docID, nil)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from get docgov document, got %d", w.Code)
}
// 6. 查阅并认领文档
claimReq := DocGovClaimRequest{
DocID: docID,
UserID: 101,
UserName: "张工",
UserRole: "author",
}
claimBytes, _ := json.Marshal(claimReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/claim", bytes.NewReader(claimBytes))
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from claim doc, got %d", w.Code)
}
// 7. 已认领文档再次运行被拦截,生成变更提案
taskReq2 := taskReq
taskReq2.TargetMarket = "欧盟CE"
taskBytes2, _ := json.Marshal(taskReq2)
req = httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run", bytes.NewReader(taskBytes2))
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with proposal from tasks/run, got %d", w.Code)
}
var interceptedRes bizdev.TaskRunResult
_ = json.NewDecoder(w.Body).Decode(&interceptedRes)
if !interceptedRes.RequiresAuthorApproval || interceptedRes.PendingProposal == nil {
t.Fatalf("expected RequiresAuthorApproval=true and non-nil PendingProposal")
}
// 8. 编制人拍板同意变更提案
decideReq := DecideProposalRequest{
ProposalID: interceptedRes.PendingProposal.ProposalID,
AuthorID: 101,
Approve: true,
Reason: "经评估符合出海战略,同意增补CE条款",
}
decideBytes, _ := json.Marshal(decideReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal/decide", bytes.NewReader(decideBytes))
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from proposal/decide, got %d", w.Code)
}
// 9. 添加批注
commReq := AddCommentRequestDTO{
DocID: docID,
UserID: 102,
UserName: "李主管",
UserRole: "reviewer",
TargetSection: "## 法规结论",
CommentType: "suggestion",
Content: "建议补充针对大血管闭合的YY/T 0644条款",
}
commBytes, _ := json.Marshal(commReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/comments", bytes.NewReader(commBytes))
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from add comment, got %d", w.Code)
}
// 10. 送审
submitReq := DocGovSubmitReviewRequest{
DocID: docID,
UserID: 101,
UserName: "张工",
}
submitBytes, _ := json.Marshal(submitReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBytes))
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from submit review, got %d", w.Code)
}
// 11. 审核驳回校验 (空原因应被拦截)
rejReq := DocGovDecisionRequest{
DocID: docID,
UserID: 102,
UserName: "李主管",
Action: "reject",
Reason: "",
}
rejBytes, _ := json.Marshal(rejReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/review-decision", bytes.NewReader(rejBytes))
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty reject reason, got %d", w.Code)
}
// 12. 审核通过
appReq := DocGovDecisionRequest{
DocID: docID,
UserID: 102,
UserName: "李主管",
Action: "approve",
Comment: "初审符合注册法规要求,同意转交批准",
}
appBytes, _ := json.Marshal(appReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/review-decision", bytes.NewReader(appBytes))
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for review approve, got %d", w.Code)
}
// 13. 批准人批准
apprReq := DocGovDecisionRequest{
DocID: docID,
UserID: 103,
UserName: "王总监",
Action: "approve",
Comment: "准予受控生效",
}
apprBytes, _ := json.Marshal(apprReq)
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/approver-decision", bytes.NewReader(apprBytes))
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for approver approve, got %d", w.Code)
}
// 14. 查询审计日志
req = httptest.NewRequest(http.MethodGet, "/api/v1/docgov/audit-logs?doc_id="+docID, nil)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from audit logs, got %d", w.Code)
}
}