Library must not abort the process; E2E callers use EnsureOnline/t.Fatal so ambient RUN_PI_E2E no longer kills short server tests. Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
12 KiB
Go
306 lines
12 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/kb"
|
|
"chat-agent-system/internal/projectinstance"
|
|
"chat-agent-system/internal/projectworkflow"
|
|
"chat-agent-system/internal/server"
|
|
"chat-agent-system/internal/workflowdef"
|
|
)
|
|
|
|
func piAgentRunnerHealthy() bool {
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
resp, err := client.Get("http://127.0.0.1:18865/health")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode == http.StatusOK
|
|
}
|
|
|
|
func setupPGPiAgentServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) {
|
|
t.Helper()
|
|
cfg := configFromTestDatabaseURL(t)
|
|
cfg.DataDir = piAgentSharedDataDir(t)
|
|
cfg.AgentBackend = "pi-agent"
|
|
cfg.AgentTimeout = 60 * time.Minute
|
|
cfg.PiAgentRunnerURL = envOr("PI_AGENT_RUNNER_URL", "http://127.0.0.1:18865")
|
|
cfg.PiAgentRunnerAuthToken = envOr("PI_AGENT_RUNNER_AUTH_TOKEN", envOr("RUNNER_AUTH_TOKEN", ""))
|
|
cfg.PiAgentModel = envOr("PI_AGENT_MODEL", "MiniMax-M2.7-highspeed")
|
|
cfg.PiAgentProvider = envOr("PI_AGENT_PROVIDER", "minimax")
|
|
if cfg.PiAgentRunnerAuthToken == "" {
|
|
t.Skip("RUNNER_AUTH_TOKEN not set; skipping pi-agent PG E2E")
|
|
}
|
|
mergePiAgentE2EKbConfig(cfg)
|
|
svc := bizdev.MustNewService(t, cfg)
|
|
if bizdev.Snapshot(svc).Pool == nil {
|
|
t.Skip("bizdev service not connected to postgres")
|
|
}
|
|
if err := kb.EnsureOnline(bizdev.Snapshot(svc).KB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := server.NewServer(cfg, svc)
|
|
return srv.Routes(), srv
|
|
}
|
|
|
|
type piAgentGovernanceCase struct {
|
|
WfKey string
|
|
TaskKey string
|
|
StageKey string
|
|
ProjectName string
|
|
ExpectPlanningDone bool
|
|
Materials []bizdev.MaterialInput
|
|
}
|
|
|
|
func runPiAgentGovernanceE2E(t *testing.T, spec piAgentGovernanceCase) {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping slow pi-agent PG E2E in short mode")
|
|
}
|
|
if !piAgentRunnerHealthy() {
|
|
t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health")
|
|
}
|
|
|
|
pool := openPGPoolWithMigrations(t)
|
|
if pool == nil {
|
|
return
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
handler, srv := setupPGPiAgentServer(t, pool)
|
|
ts := httptest.NewServer(handler)
|
|
defer ts.Close()
|
|
client := &http.Client{Timeout: 75 * time.Minute}
|
|
|
|
seedOfficialWorkflowManifest(t, pool, spec.WfKey)
|
|
|
|
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
|
|
reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456")
|
|
approverToken := loginTestUser(t, handler, "wangzong", "123456")
|
|
creatorID := fetchAuthUserID(t, handler, creatorToken)
|
|
reviewerID := fetchAuthUserID(t, handler, reviewerToken)
|
|
approverID := fetchAuthUserID(t, handler, approverToken)
|
|
|
|
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
|
Name: spec.ProjectName,
|
|
DeviceType: "Ultrasonic Device",
|
|
IntendedUse: "Tissue resection",
|
|
TargetMarket: "NMPA Class III",
|
|
})
|
|
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
|
withBearerAuth(reqCreate, creatorToken)
|
|
recCreate := httptest.NewRecorder()
|
|
handler.ServeHTTP(recCreate, reqCreate)
|
|
if recCreate.Code != http.StatusCreated {
|
|
t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String())
|
|
}
|
|
var proj projectworkflow.Project
|
|
if err := json.Unmarshal(recCreate.Body.Bytes(), &proj); err != nil {
|
|
t.Fatalf("decode project: %v", err)
|
|
}
|
|
t.Cleanup(func() { cleanupProject(t, pool, proj.ID) })
|
|
projectID := proj.ID
|
|
t.Logf("✓ [1. 项目创建] id=%d code=%s wf=%s", projectID, proj.Code, spec.WfKey)
|
|
|
|
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, spec.WfKey), nil)
|
|
withBearerAuth(reqDefault, creatorToken)
|
|
recDefault := httptest.NewRecorder()
|
|
handler.ServeHTTP(recDefault, reqDefault)
|
|
if recDefault.Code != http.StatusOK {
|
|
t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String())
|
|
}
|
|
var plan projectinstance.WorkflowPlan
|
|
_ = json.Unmarshal(recDefault.Body.Bytes(), &plan)
|
|
ensureProjectMembers(t, pool, projectID, map[int64]string{
|
|
creatorID: "owner",
|
|
reviewerID: "member",
|
|
approverID: "member",
|
|
})
|
|
for i := range plan.Stages {
|
|
for j := range plan.Stages[i].DocTasks {
|
|
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
|
|
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
|
|
}
|
|
}
|
|
}
|
|
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
|
|
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody))
|
|
withBearerAuth(reqSave, creatorToken)
|
|
recSave := httptest.NewRecorder()
|
|
handler.ServeHTTP(recSave, reqSave)
|
|
if recSave.Code != http.StatusOK {
|
|
t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String())
|
|
}
|
|
|
|
reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil)
|
|
withBearerAuth(reqPreview, creatorToken)
|
|
recPreview := httptest.NewRecorder()
|
|
handler.ServeHTTP(recPreview, reqPreview)
|
|
var preview projectinstance.ActivationPreview
|
|
_ = json.Unmarshal(recPreview.Body.Bytes(), &preview)
|
|
|
|
activateBody, _ := json.Marshal(projectinstance.ActivateInput{
|
|
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
|
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
|
})
|
|
reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
|
|
withBearerAuth(reqActivate, creatorToken)
|
|
recActivate := httptest.NewRecorder()
|
|
handler.ServeHTTP(recActivate, reqActivate)
|
|
if recActivate.Code != http.StatusOK {
|
|
t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String())
|
|
}
|
|
var activated projectinstance.ActivateResult
|
|
_ = json.Unmarshal(recActivate.Body.Bytes(), &activated)
|
|
instanceID := activated.Instance.ID
|
|
t.Logf("✓ [2. 工作流激活] instance_id=%d", instanceID)
|
|
|
|
taskStart := time.Now()
|
|
taskBody, _ := json.Marshal(server.TaskRunRequestDTO{
|
|
TaskKey: spec.TaskKey,
|
|
ProjectName: proj.Name,
|
|
DeviceType: proj.DeviceType,
|
|
IntendedUse: "Tissue resection",
|
|
TargetMarket: "NMPA Class III",
|
|
Backend: "pi-agent",
|
|
ProjectID: projectID,
|
|
AuthorID: creatorID,
|
|
ReviewerID: reviewerID,
|
|
ApproverID: approverID,
|
|
Fields: map[string]string{
|
|
"customer_name": "PG PiAgent 治理链客户",
|
|
"device_type": proj.DeviceType,
|
|
"intended_use": "Tissue resection",
|
|
"target_market": "NMPA Class III",
|
|
},
|
|
Materials: spec.Materials,
|
|
})
|
|
reqTask, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run", bytes.NewReader(taskBody))
|
|
withBearerAuth(reqTask, creatorToken)
|
|
respTask, err := client.Do(reqTask)
|
|
if err != nil {
|
|
t.Fatalf("pi-agent task run: %v", err)
|
|
}
|
|
defer respTask.Body.Close()
|
|
if respTask.StatusCode != http.StatusOK {
|
|
var buf bytes.Buffer
|
|
_, _ = buf.ReadFrom(respTask.Body)
|
|
t.Fatalf("pi-agent task HTTP %d: %s", respTask.StatusCode, buf.String())
|
|
}
|
|
var taskRes struct {
|
|
Success bool `json:"success"`
|
|
DocRecord *struct {
|
|
DocID string `json:"doc_id"`
|
|
} `json:"doc_record"`
|
|
ArtifactTitle string `json:"artifact_title"`
|
|
}
|
|
_ = json.NewDecoder(respTask.Body).Decode(&taskRes)
|
|
if !taskRes.Success {
|
|
t.Fatalf("pi-agent %s task failed", spec.TaskKey)
|
|
}
|
|
docID := fmt.Sprintf("doc_proj%d_%s", projectID, spec.TaskKey)
|
|
if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" {
|
|
docID = taskRes.DocRecord.DocID
|
|
}
|
|
t.Logf("✓ [3. pi-agent %s] doc=%s title=%s elapsed=%s", spec.TaskKey, docID, taskRes.ArtifactTitle, time.Since(taskStart).Round(time.Second))
|
|
|
|
docEngine := bizdev.Snapshot(srv.BizdevService()).Gov
|
|
_ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_pi_pg", false)
|
|
_, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author")
|
|
_, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok")
|
|
_, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok")
|
|
if err != nil {
|
|
t.Fatalf("approve doc: %v", err)
|
|
}
|
|
t.Log("✓ [4. 文档批准] approve chain complete")
|
|
|
|
reqNotify := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/notifications", projectID), nil)
|
|
withBearerAuth(reqNotify, creatorToken)
|
|
recNotify := httptest.NewRecorder()
|
|
handler.ServeHTTP(recNotify, reqNotify)
|
|
if recNotify.Code != http.StatusOK {
|
|
t.Fatalf("list notifications: %d %s", recNotify.Code, recNotify.Body.String())
|
|
}
|
|
// Notifications are delivered via outbox worker; do not require sync stage_ready here.
|
|
|
|
advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{
|
|
StageKey: spec.StageKey, ExpectedConfigRevision: activated.Instance.ConfigRevision,
|
|
})
|
|
reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/%s/advance", instanceID, spec.StageKey), bytes.NewReader(advanceBody))
|
|
withBearerAuth(reqAdvance, creatorToken)
|
|
recAdvance := httptest.NewRecorder()
|
|
handler.ServeHTTP(recAdvance, reqAdvance)
|
|
if recAdvance.Code != http.StatusOK {
|
|
t.Fatalf("advance: %d %s", recAdvance.Code, recAdvance.Body.String())
|
|
}
|
|
if spec.ExpectPlanningDone {
|
|
reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", projectID), nil)
|
|
withBearerAuth(reqSettings, creatorToken)
|
|
recSettings := httptest.NewRecorder()
|
|
handler.ServeHTTP(recSettings, reqSettings)
|
|
var settings projectinstance.ProjectSettings
|
|
_ = json.Unmarshal(recSettings.Body.Bytes(), &settings)
|
|
if settings.LifecycleStatus != projectinstance.LifecyclePlanning {
|
|
t.Fatalf("expected planning after completion, got %q", settings.LifecycleStatus)
|
|
}
|
|
}
|
|
t.Logf("✓ [5. 阶段推进] PG + pi-agent 治理全链路完成 wf=%s", spec.WfKey)
|
|
}
|
|
|
|
// TestWorkflowGovernance_PiAgent_PG_FullChain — 正式 main + pi-agent intake
|
|
func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) {
|
|
runPiAgentGovernanceE2E(t, piAgentGovernanceCase{
|
|
WfKey: workflowdef.OfficialKeyMain,
|
|
TaskKey: "intake",
|
|
StageKey: "V0.1_triage",
|
|
ProjectName: "PG PiAgent 治理链中文项目",
|
|
Materials: []bizdev.MaterialInput{{
|
|
Filename: "project_brief.md",
|
|
Content: []byte("# 项目简报\n\n- 产品:超声软组织手术系统\n- 目标:NMPA III 类注册路径摸底\n- 关键需求:双频刀头、灌注与负压吸引\n"),
|
|
}},
|
|
})
|
|
}
|
|
|
|
// TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain — 正式 product_manager + pi-agent system_reqs
|
|
func TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain(t *testing.T) {
|
|
runPiAgentGovernanceE2E(t, piAgentGovernanceCase{
|
|
WfKey: workflowdef.OfficialKeyProductManager,
|
|
TaskKey: "system_reqs",
|
|
StageKey: "requirements_baseline",
|
|
ProjectName: "PG PiAgent 产品经理治理链",
|
|
ExpectPlanningDone: true,
|
|
Materials: []bizdev.MaterialInput{{
|
|
Filename: "intake_summary.md",
|
|
Content: []byte("# 立项输入\n\n| REQ-01 | 双频超声刀头 24/35kHz | 高 |\n| REQ-02 | 灌注流量 10-50 mL/min | 高 |\n| REQ-03 | NMPA III 类注册路径 | 高 |\n"),
|
|
}},
|
|
})
|
|
}
|
|
|
|
// TestWorkflowGovernance_PiAgent_PG_TestManager_FullChain — 正式 test_manager + pi-agent
|
|
func TestWorkflowGovernance_PiAgent_PG_TestManager_FullChain(t *testing.T) {
|
|
runPiAgentGovernanceE2E(t, piAgentGovernanceCase{
|
|
WfKey: workflowdef.OfficialKeyTestManager,
|
|
TaskKey: "test_manager",
|
|
StageKey: "system_verification_planning",
|
|
ProjectName: "PG PiAgent 测试经理治理链",
|
|
ExpectPlanningDone: true,
|
|
Materials: []bizdev.MaterialInput{{
|
|
Filename: "verification_scope.md",
|
|
Content: []byte("# 验证范围\n\n- 系统验证方案及计划\n- 覆盖电气安全、EMC、软件确认\n- 目标:NMPA III 类注册配套验证\n"),
|
|
}},
|
|
})
|
|
}
|