Evaluate required document approvals before stage transition, expose readiness/advance APIs, show StageAdvanceBanner in the UI, and keep legacy workflow switch for explicit initial_workflow_key projects. Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
12 KiB
Go
279 lines
12 KiB
Go
package server_test
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"chat-agent-system/internal/bizdev"
|
||
"chat-agent-system/internal/config"
|
||
"chat-agent-system/internal/projectworkflow"
|
||
"chat-agent-system/internal/server"
|
||
)
|
||
|
||
func setupTestServerForProjectWorkflow(t *testing.T) (*server.Server, http.Handler) {
|
||
cfg := &config.Config{
|
||
DataDir: t.TempDir(),
|
||
AgentBackend: "stub",
|
||
AllowInMemory: true,
|
||
}
|
||
svc := bizdev.MustNewService(t, cfg)
|
||
srv := server.NewServer(cfg, svc)
|
||
handler := srv.Routes()
|
||
return srv, handler
|
||
}
|
||
|
||
func TestProjectWorkflowAPI_Lifecycle(t *testing.T) {
|
||
_, handler := setupTestServerForProjectWorkflow(t)
|
||
|
||
// 1. 未认证创建项目 -> 401
|
||
bodyUnauth, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
||
Name: "未授权项目",
|
||
})
|
||
reqUnauth := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyUnauth))
|
||
recUnauth := httptest.NewRecorder()
|
||
handler.ServeHTTP(recUnauth, reqUnauth)
|
||
assert.Equal(t, http.StatusUnauthorized, recUnauth.Code)
|
||
|
||
// 2. 认证用户创建项目 (User 2: 张工) -> 201
|
||
bodyCreate, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
||
Name: "智能高频电刀 (ElectroCutter)",
|
||
DeviceType: "高频手术设备",
|
||
IntendedUse: "微创外科软组织切割与止血",
|
||
TargetMarket: "NMPA Class III",
|
||
})
|
||
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyCreate))
|
||
withUserIDAuth(reqCreate, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recCreate := httptest.NewRecorder()
|
||
handler.ServeHTTP(recCreate, reqCreate)
|
||
require.Equal(t, http.StatusCreated, recCreate.Code)
|
||
|
||
var createdProj projectworkflow.Project
|
||
err := json.Unmarshal(recCreate.Body.Bytes(), &createdProj)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, int64(2), createdProj.CreatorUserID)
|
||
assert.Equal(t, "planning", createdProj.LifecycleStatus)
|
||
assert.Equal(t, "", createdProj.ActiveWorkflowKey)
|
||
assert.Equal(t, 1, createdProj.WorkflowSelectionVersion)
|
||
|
||
// 3. 列出项目列表 -> 包含刚创建的项目
|
||
reqList := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
|
||
withUserIDAuth(reqList, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recList := httptest.NewRecorder()
|
||
handler.ServeHTTP(recList, reqList)
|
||
assert.Equal(t, http.StatusOK, recList.Code)
|
||
|
||
var projList []*projectworkflow.Project
|
||
err = json.Unmarshal(recList.Body.Bytes(), &projList)
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, projList)
|
||
|
||
// 4. 查询项目挂载的工作流清单 -> 包含 3 个默认工作流
|
||
reqWFs := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1001/workflows", nil)
|
||
recWFs := httptest.NewRecorder()
|
||
handler.ServeHTTP(recWFs, reqWFs)
|
||
assert.Equal(t, http.StatusOK, recWFs.Code)
|
||
|
||
var wfList []*projectworkflow.ProjectWorkflow
|
||
err = json.Unmarshal(recWFs.Body.Bytes(), &wfList)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 3, len(wfList))
|
||
|
||
// 5. planning 项目禁止旧式切换 -> 409 Conflict
|
||
bodySwitchBlocked, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{
|
||
WorkflowKey: "product_manager",
|
||
ExpectedVersion: 1,
|
||
Reason: "planning 项目应走设置激活",
|
||
})
|
||
reqSwitchBlocked := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", createdProj.ID), bytes.NewReader(bodySwitchBlocked))
|
||
withUserIDAuth(reqSwitchBlocked, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recSwitchBlocked := httptest.NewRecorder()
|
||
handler.ServeHTTP(recSwitchBlocked, reqSwitchBlocked)
|
||
assert.Equal(t, http.StatusConflict, recSwitchBlocked.Code)
|
||
assert.Contains(t, recSwitchBlocked.Body.String(), "LEGACY_SWITCH_BLOCKED")
|
||
|
||
// 6. 显式 initial_workflow_key 的 legacy 项目:ACL + 切换
|
||
bodyLegacyCreate, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
||
Name: "Legacy 切换测试项目",
|
||
InitialWorkflowKey: "main",
|
||
})
|
||
reqLegacyCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyLegacyCreate))
|
||
withUserIDAuth(reqLegacyCreate, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recLegacyCreate := httptest.NewRecorder()
|
||
handler.ServeHTTP(recLegacyCreate, reqLegacyCreate)
|
||
require.Equal(t, http.StatusCreated, recLegacyCreate.Code)
|
||
var legacyProj projectworkflow.Project
|
||
require.NoError(t, json.Unmarshal(recLegacyCreate.Body.Bytes(), &legacyProj))
|
||
assert.Equal(t, "main", legacyProj.ActiveWorkflowKey)
|
||
|
||
bodySwitchOther, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{
|
||
WorkflowKey: "product_manager",
|
||
ExpectedVersion: 1,
|
||
Reason: "非法切换",
|
||
})
|
||
reqSwitchOther := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchOther))
|
||
withUserIDAuth(reqSwitchOther, loginTestUser(t, handler, "lizhuguan", "123456"))
|
||
recSwitchOther := httptest.NewRecorder()
|
||
handler.ServeHTTP(recSwitchOther, reqSwitchOther)
|
||
assert.Equal(t, http.StatusForbidden, recSwitchOther.Code)
|
||
|
||
bodySwitchCreator, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{
|
||
WorkflowKey: "product_manager",
|
||
ExpectedVersion: 1,
|
||
Reason: "切换至智能产品经理编制需求",
|
||
})
|
||
reqSwitchCreator := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchCreator))
|
||
withUserIDAuth(reqSwitchCreator, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recSwitchCreator := httptest.NewRecorder()
|
||
handler.ServeHTTP(recSwitchCreator, reqSwitchCreator)
|
||
require.Equal(t, http.StatusOK, recSwitchCreator.Code)
|
||
|
||
var switchResult projectworkflow.ActivateWorkflowResult
|
||
err = json.Unmarshal(recSwitchCreator.Body.Bytes(), &switchResult)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "product_manager", switchResult.ActiveWorkflowKey)
|
||
assert.Equal(t, 2, switchResult.Version)
|
||
|
||
// 7. 再次用旧版本 1 切换 -> 409 Conflict
|
||
bodySwitchConflict, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{
|
||
WorkflowKey: "test_manager",
|
||
ExpectedVersion: 1,
|
||
Reason: "并发冲突测试",
|
||
})
|
||
reqSwitchConflict := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchConflict))
|
||
withUserIDAuth(reqSwitchConflict, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recSwitchConflict := httptest.NewRecorder()
|
||
handler.ServeHTTP(recSwitchConflict, reqSwitchConflict)
|
||
assert.Equal(t, http.StatusConflict, recSwitchConflict.Code)
|
||
|
||
// 8. 查询全局工作流规范目录 -> 200 OK
|
||
reqDefs := httptest.NewRequest(http.MethodGet, "/api/v1/workflows", nil)
|
||
recDefs := httptest.NewRecorder()
|
||
handler.ServeHTTP(recDefs, reqDefs)
|
||
assert.Equal(t, http.StatusOK, recDefs.Code)
|
||
var defs []*projectworkflow.Definition
|
||
err = json.Unmarshal(recDefs.Body.Bytes(), &defs)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 3, len(defs))
|
||
|
||
// 9. 触发工作流运行 -> 200 OK
|
||
bodyRun, _ := json.Marshal(map[string]any{
|
||
"task_key": "intake",
|
||
"project_name": "智能高频电刀",
|
||
"device_type": "高频手术设备",
|
||
"backend": "stub",
|
||
})
|
||
reqRun := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1001/workflows/main/run", bytes.NewReader(bodyRun))
|
||
withUserIDAuth(reqRun, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recRun := httptest.NewRecorder()
|
||
handler.ServeHTTP(recRun, reqRun)
|
||
assert.Equal(t, http.StatusOK, recRun.Code)
|
||
|
||
// 10. 查询运行历史 -> 200 OK 且包含刚运行的记录
|
||
reqRuns := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1001/workflows/main/runs", nil)
|
||
recRuns := httptest.NewRecorder()
|
||
handler.ServeHTTP(recRuns, reqRuns)
|
||
assert.Equal(t, http.StatusOK, recRuns.Code)
|
||
var runsList []*projectworkflow.WorkflowRun
|
||
err = json.Unmarshal(recRuns.Body.Bytes(), &runsList)
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, runsList)
|
||
assert.Contains(t, []string{"completed", "quality_failed"}, runsList[0].Status)
|
||
}
|
||
|
||
func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) {
|
||
srv, handler := setupTestServerForProjectWorkflow(t)
|
||
|
||
// 1. 创建测试项目
|
||
bodyCreate, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
||
Name: "智能肺功能仪",
|
||
DeviceType: "呼吸诊疗设备",
|
||
IntendedUse: "肺通气功能评测",
|
||
TargetMarket: "NMPA Class II",
|
||
})
|
||
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyCreate))
|
||
withUserIDAuth(reqCreate, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recCreate := httptest.NewRecorder()
|
||
handler.ServeHTTP(recCreate, reqCreate)
|
||
require.Equal(t, http.StatusCreated, recCreate.Code)
|
||
var proj projectworkflow.Project
|
||
require.NoError(t, json.Unmarshal(recCreate.Body.Bytes(), &proj))
|
||
require.Positive(t, proj.ID)
|
||
projectID := proj.ID
|
||
|
||
// 2. 在未批准系统需求规格书时,直接触发 test_manager -> 应触发前置阻断 400 / ERR_PRECONDITION_FAILED
|
||
bodyRunTestFail, _ := json.Marshal(map[string]any{
|
||
"task_key": "test_manager",
|
||
"project_name": proj.Name,
|
||
"backend": "stub",
|
||
})
|
||
reqRunTestFail := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflows/test_manager/run", projectID), bytes.NewReader(bodyRunTestFail))
|
||
withUserIDAuth(reqRunTestFail, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recRunTestFail := httptest.NewRecorder()
|
||
handler.ServeHTTP(recRunTestFail, reqRunTestFail)
|
||
assert.Equal(t, http.StatusBadRequest, recRunTestFail.Code)
|
||
assert.Contains(t, recRunTestFail.Body.String(), "前置条件未满足")
|
||
|
||
// 3. 在受控治理中创建并批准系统需求规格书
|
||
srDocID := fmt.Sprintf("doc_proj%d_system_reqs", projectID)
|
||
_, err := srv.BizdevService().DocGov().CreateOrGetDocument(
|
||
nil, srDocID, projectID, "system_reqs", "系统需求规格书",
|
||
"V0.5_feasibility", "",
|
||
2, "张工", 3, "李主管", 4, "王总监",
|
||
)
|
||
require.NoError(t, err)
|
||
_ = srv.BizdevService().DocGov().MarkDocumentContentUpdated(nil, srDocID, "hash_v1", false)
|
||
_, _ = srv.BizdevService().DocGov().SubmitForReview(nil, srDocID, 2, "author", "张工")
|
||
_, _ = srv.BizdevService().DocGov().ReviewApprove(nil, srDocID, 3, "reviewer", "李主管", "同意")
|
||
approvedDoc, err := srv.BizdevService().DocGov().ApproverApprove(nil, srDocID, 4, "approver", "王总监", "批准生效")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "approved", string(approvedDoc.CurrentState))
|
||
|
||
// 4. 前置已批准后,触发 test_manager 运行 -> 成功 200
|
||
bodyRunTestSuccess, _ := json.Marshal(map[string]any{
|
||
"task_key": "test_manager",
|
||
"project_name": proj.Name,
|
||
"backend": "stub",
|
||
})
|
||
reqRunTestSuccess := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflows/test_manager/run", projectID), bytes.NewReader(bodyRunTestSuccess))
|
||
withUserIDAuth(reqRunTestSuccess, loginTestUser(t, handler, "zhanggong", "123456"))
|
||
recRunTestSuccess := httptest.NewRecorder()
|
||
handler.ServeHTTP(recRunTestSuccess, reqRunTestSuccess)
|
||
if recRunTestSuccess.Code != http.StatusOK {
|
||
t.Fatalf("test_manager run failed: %d %s", recRunTestSuccess.Code, recRunTestSuccess.Body.String())
|
||
}
|
||
|
||
// 5. 查询文档血缘 -> 存在且 is_stale == false
|
||
lineageDocID := fmt.Sprintf("doc_proj%d_test_manager", projectID)
|
||
lineageToken := loginTestUser(t, handler, "zhanggong", "123456")
|
||
reqLineage := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/lineage?doc_id=%s", projectID, lineageDocID), nil)
|
||
withUserIDAuth(reqLineage, lineageToken)
|
||
recLineage := httptest.NewRecorder()
|
||
handler.ServeHTTP(recLineage, reqLineage)
|
||
assert.Equal(t, http.StatusOK, recLineage.Code)
|
||
var lineages []*projectworkflow.DocumentLineage
|
||
_ = json.Unmarshal(recLineage.Body.Bytes(), &lineages)
|
||
require.NotEmpty(t, lineages)
|
||
assert.Equal(t, srDocID, lineages[0].UpstreamDocID)
|
||
assert.False(t, lineages[0].IsStale)
|
||
|
||
// 6. 上游 system_reqs 发生更新(模拟触发 CheckAndPropagateStale)
|
||
_, err = srv.ProjectWorkflowService().CheckAndPropagateStale(nil, srDocID, "hash_v2_changed", "需求发生重大变更")
|
||
require.NoError(t, err)
|
||
|
||
// 再次查询下游血缘 -> is_stale 应变为 true
|
||
recLineage2 := httptest.NewRecorder()
|
||
handler.ServeHTTP(recLineage2, reqLineage)
|
||
var lineagesAfter []*projectworkflow.DocumentLineage
|
||
_ = json.Unmarshal(recLineage2.Body.Bytes(), &lineagesAfter)
|
||
require.NotEmpty(t, lineagesAfter)
|
||
assert.True(t, lineagesAfter[0].IsStale)
|
||
}
|
||
|