Unify auth middleware and remove X-User-ID trust, add project_members authorization, sandbox workspaces via resolver, and admin-only reconcile by run_id. Introduce goose migrations with startup schema gate, fail closed when DB unavailable outside explicit ALLOW_IN_MEMORY, bundle web/dist in app Dockerfile, and remove frontend pseudo-login fallback.
312 lines
8.2 KiB
Go
312 lines
8.2 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/config"
|
|
"chat-agent-system/internal/stream"
|
|
)
|
|
|
|
func TestE2E_ChatSSEStreamPipeline(t *testing.T) {
|
|
cfg := &config.Config{
|
|
Env: "test",
|
|
AgentBackend: "stub",
|
|
DataDir: "./data/test",
|
|
KbDbPort: 5432,
|
|
}
|
|
svc := bizdev.MustNewService(t, cfg)
|
|
srv := NewServer(cfg, svc)
|
|
|
|
ts := httptest.NewServer(srv.Routes())
|
|
defer ts.Close()
|
|
|
|
// 1. 发起 SSE 流式对话请求
|
|
reqBody := StreamChatRequest{
|
|
SessionID: "e2e_sse_session_01",
|
|
Message: "查一下当前进展",
|
|
IncludeDetails: true,
|
|
ProjectName: "医用高清内窥镜系统",
|
|
CustomerName: "某三甲医院联合研发中心",
|
|
DeviceType: "内窥镜",
|
|
TargetMarket: "国内",
|
|
}
|
|
jsonBytes, _ := json.Marshal(reqBody)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/chat/stream", bytes.NewReader(jsonBytes))
|
|
if err != nil {
|
|
t.Fatalf("create request failed: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if !strings.Contains(contentType, "text/event-stream") {
|
|
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
|
|
}
|
|
|
|
// 2. 流式解析读取
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
var events []stream.StreamEvent
|
|
var currentEventType string
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "event: ") {
|
|
currentEventType = 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 {
|
|
if ev.Type == "" {
|
|
ev.Type = stream.EventType(currentEventType)
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
t.Fatalf("expected streamed events, got 0")
|
|
}
|
|
|
|
// 3. 验证关键事件存在性与顺序
|
|
foundPhase := false
|
|
foundStatus := false
|
|
foundDone := false
|
|
|
|
for _, ev := range events {
|
|
switch ev.Type {
|
|
case stream.EventPhase:
|
|
foundPhase = true
|
|
if ev.Phase == nil || ev.Phase.Message == "" {
|
|
t.Errorf("expected non-empty phase message")
|
|
}
|
|
case stream.EventStatus:
|
|
foundStatus = true
|
|
if ev.Status == nil || ev.Status.Intent == "" {
|
|
t.Errorf("expected non-empty status payload")
|
|
}
|
|
case stream.EventDone:
|
|
foundDone = true
|
|
}
|
|
}
|
|
|
|
if !foundPhase {
|
|
t.Errorf("expected EventPhase in stream")
|
|
}
|
|
if !foundStatus {
|
|
t.Errorf("expected EventStatus in stream")
|
|
}
|
|
if !foundDone {
|
|
t.Errorf("expected EventDone in stream")
|
|
}
|
|
}
|
|
|
|
func TestE2E_TasksRunStreamPipeline(t *testing.T) {
|
|
cfg := &config.Config{
|
|
Env: "test",
|
|
AgentBackend: "stub",
|
|
DataDir: "./data/test",
|
|
KbDbPort: 5432,
|
|
}
|
|
svc := bizdev.MustNewService(t, cfg)
|
|
srv := NewServer(cfg, svc)
|
|
|
|
ts := httptest.NewServer(srv.Routes())
|
|
defer ts.Close()
|
|
|
|
reqBody := TaskRunRequestDTO{
|
|
TaskKey: "intake",
|
|
ProjectName: "超声内镜系统",
|
|
CustomerName: "北京水木东方",
|
|
DeviceType: "超声主机",
|
|
Backend: "stub",
|
|
}
|
|
jsonBytes, _ := json.Marshal(reqBody)
|
|
|
|
resp, err := http.Post(ts.URL+"/api/v1/tasks/run/stream", "application/json", bytes.NewReader(jsonBytes))
|
|
if err != nil {
|
|
t.Fatalf("post tasks run stream failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
eventCount := 0
|
|
hasDeliverable := false
|
|
hasDone := false
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "data: ") {
|
|
eventCount++
|
|
var ev stream.StreamEvent
|
|
dataStr := strings.TrimPrefix(line, "data: ")
|
|
if err := json.Unmarshal([]byte(dataStr), &ev); err == nil {
|
|
if ev.Type == stream.EventDeliverable {
|
|
hasDeliverable = true
|
|
}
|
|
if ev.Type == stream.EventDone {
|
|
hasDone = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if eventCount == 0 {
|
|
t.Fatalf("expected task run events, got 0")
|
|
}
|
|
if !hasDeliverable {
|
|
t.Errorf("expected EventDeliverable in task stream")
|
|
}
|
|
if !hasDone {
|
|
t.Errorf("expected EventDone in task stream")
|
|
}
|
|
}
|
|
|
|
func TestE2E_ClaimedDocument_SSE_Interception(t *testing.T) {
|
|
cfg := &config.Config{
|
|
Env: "test",
|
|
AgentBackend: "stub",
|
|
DataDir: t.TempDir(),
|
|
KbDbPort: 5432,
|
|
AdminUsername: "admin",
|
|
AdminPassword: "admin-secret-password-123",
|
|
}
|
|
svc := bizdev.MustNewService(t, cfg)
|
|
srv := NewServer(cfg, svc)
|
|
|
|
ts := httptest.NewServer(srv.Routes())
|
|
defer ts.Close()
|
|
|
|
projectID := int64(999)
|
|
authorID := int64(101)
|
|
|
|
// 1. 首次触发任务执行 (生成正式产物)
|
|
taskReq1 := TaskRunRequestDTO{
|
|
TaskKey: "feasibility_reg",
|
|
ProjectName: "智能微创手术机器人",
|
|
CustomerName: "北京某三甲医院",
|
|
DeviceType: "腔镜手术机器人",
|
|
Backend: "stub",
|
|
ProjectID: projectID,
|
|
AuthorID: authorID,
|
|
AuthorName: "张工",
|
|
}
|
|
reqBytes1, _ := json.Marshal(taskReq1)
|
|
res1, err := http.Post(ts.URL+"/api/v1/tasks/run", "application/json", bytes.NewReader(reqBytes1))
|
|
if err != nil || res1.StatusCode != http.StatusOK {
|
|
t.Fatalf("first task run failed: %v", err)
|
|
}
|
|
var taskRes1 bizdev.TaskRunResult
|
|
if err := json.NewDecoder(res1.Body).Decode(&taskRes1); err != nil {
|
|
t.Fatalf("decode first task run: %v", err)
|
|
}
|
|
res1.Body.Close()
|
|
docID := "doc_proj999_feasibility_reg"
|
|
if taskRes1.DocRecord != nil && taskRes1.DocRecord.DocID != "" {
|
|
docID = taskRes1.DocRecord.DocID
|
|
}
|
|
|
|
// 2. 编制人查阅正文触发认领
|
|
claimReq := DocGovClaimRequest{
|
|
DocID: docID,
|
|
UserID: authorID,
|
|
UserName: "张工",
|
|
UserRole: "author",
|
|
}
|
|
claimBytes, _ := json.Marshal(claimReq)
|
|
claimHTTP, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/docgov/claim", bytes.NewReader(claimBytes))
|
|
claimHTTP.Header.Set("Content-Type", "application/json")
|
|
withBearerAuth(claimHTTP, loginTestAdmin(t, srv))
|
|
resClaim, err := http.DefaultClient.Do(claimHTTP)
|
|
if err != nil || resClaim.StatusCode != http.StatusOK {
|
|
t.Fatalf("docgov claim failed: status=%v err=%v", func() int {
|
|
if resClaim != nil {
|
|
return resClaim.StatusCode
|
|
}
|
|
return 0
|
|
}(), err)
|
|
}
|
|
resClaim.Body.Close()
|
|
|
|
// 3. 已认领文档通过 SSE 流式触发再次执行 -> 验证拦截并推送 governance 与 summary 关键节点事件
|
|
taskReq2 := taskReq1
|
|
taskReq2.TargetMarket = "欧盟CE+美国FDA"
|
|
reqBytes2, _ := json.Marshal(taskReq2)
|
|
|
|
respStream, err := http.Post(ts.URL+"/api/v1/tasks/run/stream", "application/json", bytes.NewReader(reqBytes2))
|
|
if err != nil {
|
|
t.Fatalf("post tasks run stream failed: %v", err)
|
|
}
|
|
defer respStream.Body.Close()
|
|
|
|
if respStream.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", respStream.StatusCode)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(respStream.Body)
|
|
var events []stream.StreamEvent
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "data: ") {
|
|
var ev stream.StreamEvent
|
|
dataStr := strings.TrimPrefix(line, "data: ")
|
|
if err := json.Unmarshal([]byte(dataStr), &ev); err == nil {
|
|
events = append(events, ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
foundGovernance := false
|
|
foundSummary := false
|
|
var propID string
|
|
|
|
for _, ev := range events {
|
|
if ev.Type == stream.EventGovernance && ev.Governance != nil {
|
|
foundGovernance = true
|
|
if ev.Governance.Action == "proposal_required" && ev.Governance.AuthorClaimed {
|
|
propID = ev.Governance.ProposalID
|
|
t.Logf("✓ SSE 流中成功捕获文档治理拦截事件: proposal_id=%s, 简报=%s",
|
|
propID, ev.Governance.NotificationTitle)
|
|
}
|
|
}
|
|
if ev.Type == stream.EventSummary && ev.Summary != nil {
|
|
foundSummary = true
|
|
t.Logf("✓ SSE 流中成功捕获轻量级摘要事件: 标题=%s, 内容=%s",
|
|
ev.Summary.Title, ev.Summary.Content)
|
|
}
|
|
}
|
|
|
|
if !foundGovernance {
|
|
t.Errorf("expected EventGovernance in SSE stream for claimed document")
|
|
}
|
|
if !foundSummary {
|
|
t.Errorf("expected EventSummary in SSE stream for claimed document")
|
|
}
|
|
if propID == "" {
|
|
t.Errorf("expected valid proposal_id in governance event")
|
|
}
|
|
}
|