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/docgov" "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) } } func TestServer_DocGovProposeAndDecideHTTP(t *testing.T) { srv := newTestServer(t) token := loginTestAdmin(t, srv) ctx := context.Background() docID := "doc_proj1_proposal_http" if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "feasibility_reg", "法规评估", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatalf("CreateOrGetDocument: %v", err) } if _, err := srv.gov().RecordDocumentViewed(ctx, docID, 1, docgov.RoleAuthor, "admin"); err != nil { t.Fatalf("claim: %v", err) } proposeBody, _ := json.Marshal(ProposeModificationRequest{ DocID: docID, TriggeredBy: "web_ui", ProposedChanges: "补充 EMC 滤波指标", ImpactAnalysis: "影响原理图选型", RiskAssessment: "低风险", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal", bytes.NewReader(proposeBody)) withBearerAuth(req, token) w := httptest.NewRecorder() srv.Routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("propose want 200 got %d %s", w.Code, w.Body.String()) } var prop map[string]any if err := json.NewDecoder(w.Body).Decode(&prop); err != nil { t.Fatalf("decode propose: %v", err) } proposalID, _ := prop["proposal_id"].(string) if proposalID == "" { t.Fatalf("missing proposal_id: %#v", prop) } if status, _ := prop["status"].(string); status != "pending_author_approval" { t.Fatalf("want pending_author_approval got %v", prop["status"]) } decideBody, _ := json.Marshal(DecideProposalRequest{ ProposalID: proposalID, Approve: true, Reason: "同意补充", }) req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal/decide", bytes.NewReader(decideBody)) withBearerAuth(req, token) w = httptest.NewRecorder() srv.Routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("decide want 200 got %d %s", w.Code, w.Body.String()) } var decided map[string]any if err := json.NewDecoder(w.Body).Decode(&decided); err != nil { t.Fatalf("decode decide: %v", err) } if status, _ := decided["status"].(string); status != "approved_by_author" { t.Fatalf("want approved_by_author got %v", decided["status"]) } }