test(server): add Wave 2 real-PI evidence for U8 and full/none

Replace the U8 stop-gate with a PG+PI confirm/generate harness and add a
mixed-duty governance long chain covering cross-user approve and none
non-blocking advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-16 10:42:20 +00:00
co-authored by Cursor
parent aac42936bc
commit f1e87fb2e7
4 changed files with 648 additions and 5 deletions
+3 -5
View File
@@ -2,7 +2,6 @@ package app
import (
"context"
"os"
"testing"
"time"
@@ -121,8 +120,7 @@ func TestLiveTaskStarterReplaysIdempotencyKey(t *testing.T) {
}
func TestTaskGenerateRealPIStopGate(t *testing.T) {
if os.Getenv("PI_AGENT_RUNNER_URL") == "" {
t.Skip("stop-gate: PI_AGENT_RUNNER_URL not set; real PI generate is not claimed")
}
t.Skip("live PI generate path is gated; unit evidence is TaskRunAdapter + ClaimRun idempotency")
// Real PI + PG + revoke/restart evidence lives in
// TestMultiIntentU8_RealPI_ConfirmGenerate_PG (internal/server).
t.Skip("redirect: run TestMultiIntentU8_RealPI_ConfirmGenerate_PG with TEST_DATABASE_URL + runner")
}
@@ -0,0 +1,322 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"chat-agent-system/internal/app"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/events"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/workflowdef"
)
// TestMultiIntentU8_RealPI_ConfirmGenerate_PG closes the U8 real-PI evidence gap:
// confirm-before-start, live pi-agent generate via TaskRunAdapter, idempotent replay,
// revoke, and PG plan recovery after a fresh Plans wiring (restart simulation).
func TestMultiIntentU8_RealPI_ConfirmGenerate_PG(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow U8 real-PI PG E2E in short mode")
}
if !piAgentRunnerHealthy() {
t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health")
}
if os.Getenv("PI_AGENT_RUNNER_URL") == "" {
t.Setenv("PI_AGENT_RUNNER_URL", "http://127.0.0.1:18865")
}
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
ctx := context.Background()
handler, srv := setupPGPiAgentServer(t, pool)
seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain)
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: fmt.Sprintf("U8 Wave2 PI %d", time.Now().UnixNano()%1_000_000),
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
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), 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 wfPlan projectinstance.WorkflowPlan
_ = json.Unmarshal(recDefault.Body.Bytes(), &wfPlan)
ensureProjectMembers(t, pool, projectID, map[int64]string{
creatorID: "owner",
reviewerID: "member",
approverID: "member",
})
for i := range wfPlan.Stages {
for j := range wfPlan.Stages[i].DocTasks {
wfPlan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
}
}
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: wfPlan, 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())
}
sessionID := fmt.Sprintf("sess-u8-wave2-%d", projectID)
sessBody, _ := json.Marshal(map[string]any{
"session_id": sessionID,
"project_id": projectID,
"user_id": creatorID,
"title": "U8 Wave2 intent plan",
})
reqSess := httptest.NewRequest(http.MethodPost, "/api/v1/chat/sessions", bytes.NewReader(sessBody))
withBearerAuth(reqSess, creatorToken)
recSess := httptest.NewRecorder()
handler.ServeHTTP(recSess, reqSess)
if recSess.Code != http.StatusOK && recSess.Code != http.StatusCreated {
t.Fatalf("upsert chat session: %d %s", recSess.Code, recSess.Body.String())
}
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
taskOwner := app.NewTaskRunAdapter()
var runID string
taskReq := bizdev.TaskRunRequest{
TaskKey: "intake",
ProjectName: proj.Name,
DeviceType: proj.DeviceType,
IntendedUse: "Tissue resection",
TargetMarket: "NMPA Class III",
Backend: "pi-agent",
ProjectID: projectID,
UserID: creatorID,
AuthorID: creatorID,
ReviewerID: reviewerID,
ApproverID: approverID,
SessionID: sessionID,
Fields: map[string]string{
"customer_name": "U8 Wave2 真实 PI 客户",
"device_type": proj.DeviceType,
"intended_use": "Tissue resection",
"target_market": "NMPA Class III",
},
Materials: []bizdev.MaterialInput{{
Filename: "project_brief.md",
Content: []byte("# U8 Wave2\n\n- 产品:超声软组织手术系统\n- 目标:确认后真实 PI generate\n"),
}},
}
taskOwner.Starter = func(ctx context.Context, exec intentflow.StepExec) (string, error) {
if existing, err := srv.TaskRuns().FindByIdempotencyKey(ctx, exec.IdempotencyKey); err != nil {
return "", err
} else if existing != nil && existing.RunID != "" {
runID = existing.RunID
return existing.RunID, nil
}
rec := &taskrun.RunRecord{
SessionID: exec.Principal.SessionID,
ProjectID: exec.Principal.ProjectID,
UserID: exec.Principal.UserID,
IdempotencyKey: exec.IdempotencyKey,
TaskKey: "intake",
Status: "pending",
}
req := taskReq
req.UserID = exec.Principal.UserID
req.ProjectID = exec.Principal.ProjectID
req.SessionID = exec.Principal.SessionID
created, err := srv.TaskRuns().CreateAndStart(ctx, "", rec, req, bizdev.StageRunRequest{}, "")
if err != nil {
return "", err
}
runID = created.RunID
return created.RunID, nil
}
plans := intentflow.NewPlans(uow.NewPG(pool), intentflow.NewRepository(), intentflow.Options{
Owner: intentflow.OwnerMux{
Query: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
Task: taskOwner,
},
Events: events.PGSink{},
})
prin := intentflow.Principal{
UserID: creatorID, Environment: "test", ProjectID: projectID, SessionID: sessionID,
}
created, err := plans.CreatePlan(ctx, intentflow.CreateCommand{
Principal: prin, ClientTurnID: fmt.Sprintf("u8-turn-%d", projectID), RequestDigest: "u8-wave2-d1",
WritePlan: true, ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{
{ActionKey: "regulation.query", ActionDigest: "u8-q", ExecutionMode: intentflow.ModeLive},
{ActionKey: "task.run", ActionDigest: "u8-w", ExecutionMode: intentflow.ModeLive},
},
})
if err != nil || created == nil {
t.Fatalf("create plan: %+v %v", created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM intent_plan_decisions WHERE plan_id=$1`, created.PlanID)
_, _ = pool.Exec(context.Background(), `DELETE FROM intent_plan_steps WHERE plan_id=$1`, created.PlanID)
_, _ = pool.Exec(context.Background(), `DELETE FROM intent_plans WHERE plan_id=$1`, created.PlanID)
})
if created.Status != intentflow.PlanAwaitingConfirmation {
t.Fatalf("status before confirm = %s", created.Status)
}
if taskOwner.StartCount() != 0 {
t.Fatal("generate must not start before confirm")
}
t.Logf("✓ [1. awaiting] plan_id=%s revision=%d starts=0", created.PlanID, created.Revision)
confirmed, err := plans.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: prin, PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: fmt.Sprintf("u8-n-%d", projectID), DecisionDigest: "u8-d",
Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
if taskOwner.StartCount() != 0 {
t.Fatal("confirm alone must not start generate")
}
piStart := time.Now()
view, err := plans.AdvanceEligible(ctx, "u8-wave2-worker", confirmed.PlanID)
if err != nil {
t.Fatalf("advance: %v", err)
}
if view == nil {
t.Fatal("expected advanced plan view")
}
if taskOwner.StartCount() != 1 {
t.Fatalf("generate starts = %d, plan=%+v", taskOwner.StartCount(), view)
}
if runID == "" {
t.Fatal("starter did not capture run id")
}
waitCtx, cancel := context.WithTimeout(ctx, 45*time.Minute)
defer cancel()
if err := srv.TaskRuns().WaitRunDone(waitCtx, runID); err != nil {
t.Fatalf("wait PI run %s: %v", runID, err)
}
doneRec, err := srv.TaskRuns().GetRun(runID)
if err != nil || doneRec == nil {
t.Fatalf("get run: %v", err)
}
if doneRec.Status != taskrun.StatusCompleted {
t.Fatalf("PI run status=%s want %s", doneRec.Status, taskrun.StatusCompleted)
}
t.Logf("✓ [2. real PI generate] run_id=%s status=%s elapsed=%s", runID, doneRec.Status, time.Since(piStart).Round(time.Second))
_, err = plans.AdvanceEligible(ctx, "u8-wave2-worker", confirmed.PlanID)
if err != nil {
t.Fatal(err)
}
if taskOwner.StartCount() != 1 {
t.Fatalf("replay must not restart generate, starts=%d", taskOwner.StartCount())
}
t.Log("✓ [3. idempotent replay] starts still 1")
taskOwner.Denied = true
_, err = taskOwner.Execute(ctx, intentflow.StepExec{
ActionKey: "task.run", IdempotencyKey: "post-revoke", ActionDigest: "x",
Principal: prin,
})
if err != intentflow.ErrPlanActionForbidden {
t.Fatalf("revoke want forbidden, got %v", err)
}
t.Log("✓ [4. revoke] TaskRunAdapter denied")
restarted := intentflow.NewPlans(uow.NewPG(pool), intentflow.NewRepository(), intentflow.Options{
Owner: intentflow.OwnerMux{
Query: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
Task: app.NewTaskRunAdapter(),
},
Events: events.PGSink{},
})
reloaded, err := restarted.GetPlan(ctx, prin, created.PlanID)
if err != nil || reloaded == nil {
t.Fatalf("reload plan after restart wiring: %v", err)
}
var taskOK bool
for _, s := range reloaded.Steps {
if s.ActionKey == "task.run" && s.Status == intentflow.StepSucceeded {
taskOK = true
break
}
}
if !taskOK {
t.Fatalf("restart recovery missing succeeded task.run: %+v", reloaded.Steps)
}
t.Logf("✓ [5. PG restart recovery] plan_id=%s status=%s", reloaded.PlanID, reloaded.Status)
srv.SetIntentPlansForTest(restarted)
reqGet := httptest.NewRequest(http.MethodGet, fmt.Sprintf("%s?project_id=%d&session_id=%s",
fmt.Sprintf("/api/v1/intent-plans/%s", created.PlanID), projectID, sessionID), nil)
withBearerAuth(reqGet, creatorToken)
recGet := httptest.NewRecorder()
handler.ServeHTTP(recGet, reqGet)
if recGet.Code != http.StatusOK {
t.Fatalf("GET plan after re-login: %d %s", recGet.Code, recGet.Body.String())
}
t.Logf("✓ [6. HTTP refresh] GET plan %d bytes", recGet.Body.Len())
}
+8
View File
@@ -141,6 +141,14 @@ func (s *Server) TaskRuns() *taskrun.Manager {
return s.taskRuns
}
// SetIntentPlansForTest swaps the intent-plan service (Wave 2 restart/refresh harness only).
func (s *Server) SetIntentPlansForTest(plans intentflow.Service) {
if s == nil {
return
}
s.intentPlans = plans
}
// ProjectWorkflowService 获取项目多工作流控制服务
func (s *Server) ProjectWorkflowService() *projectworkflow.Service {
return s.pwSvc
@@ -0,0 +1,315 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/server"
"chat-agent-system/internal/workflowdef"
)
// TestWorkflowGovernance_PiAgent_PG_FullNoneMixedChain validates:
// - intake = full (cross-user approve required)
// - research = none (generate OK; submit/review/approve forbidden; does not block stage)
// after advancing V0.1, research generate + stage readiness + advance V0.5.
func TestWorkflowGovernance_PiAgent_PG_FullNoneMixedChain(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow pi-agent full/none 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: 50 * time.Minute}
seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain)
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: fmt.Sprintf("FullNone Wave2 %d", time.Now().UnixNano()%1_000_000),
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
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), 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 {
stage := &plan.Stages[i]
for j := range stage.DocTasks {
dt := &stage.DocTasks[j]
switch {
case stage.StageKey == "V0.1_triage" && dt.TaskKey == "intake":
dt.Enabled = true
dt.Duties = projectinstance.DocDuty{
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
case stage.StageKey == "V0.5_feasibility" && dt.TaskKey == "research":
dt.Enabled = true
dt.Duties = projectinstance.DocDuty{} // none
default:
dt.Enabled = false
dt.Duties = projectinstance.DocDuty{}
}
}
}
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)
if recPreview.Code != http.StatusOK {
t.Fatalf("activation preview: %d %s", recPreview.Code, recPreview.Body.String())
}
var preview projectinstance.ActivationPreview
_ = json.Unmarshal(recPreview.Body.Bytes(), &preview)
if preview.Summary.GovernedDocTasks < 1 {
t.Fatalf("expected at least one full doc, summary=%+v", preview.Summary)
}
if preview.Summary.UnmanagedDocTasks < 1 {
t.Fatalf("expected research as unmanaged none, summary=%+v", preview.Summary)
}
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("✓ [1. activate] instance=%d full=intake none=research(disabled until stage)", instanceID)
runPI := func(taskKey string, materials []bizdev.MaterialInput) string {
t.Helper()
start := time.Now()
taskBody, _ := json.Marshal(server.TaskRunRequestDTO{
TaskKey: 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": "FullNone Wave2 客户",
"device_type": proj.DeviceType,
"intended_use": "Tissue resection",
"target_market": "NMPA Class III",
},
Materials: 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 %s: %v", taskKey, err)
}
defer respTask.Body.Close()
if respTask.StatusCode != http.StatusOK {
var buf bytes.Buffer
_, _ = buf.ReadFrom(respTask.Body)
t.Fatalf("pi-agent %s HTTP %d: %s", taskKey, respTask.StatusCode, buf.String())
}
var taskRes struct {
Success bool `json:"success"`
DocRecord *struct {
DocID string `json:"doc_id"`
} `json:"doc_record"`
}
_ = json.NewDecoder(respTask.Body).Decode(&taskRes)
if !taskRes.Success {
t.Fatalf("pi-agent %s failed", taskKey)
}
docID := fmt.Sprintf("doc_proj%d_%s", projectID, taskKey)
if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" {
docID = taskRes.DocRecord.DocID
}
t.Logf("✓ pi-agent %s doc=%s elapsed=%s", taskKey, docID, time.Since(start).Round(time.Second))
return docID
}
intakeDoc := runPI("intake", []bizdev.MaterialInput{{
Filename: "project_brief.md",
Content: []byte("# FullNone\n\nintake full duty chain\n"),
}})
docEngine := bizdev.Snapshot(srv.BizdevService()).Gov
_ = docEngine.MarkDocumentContentUpdated(ctx, intakeDoc, "hash_fullnone_intake", false)
if _, err := docEngine.SubmitForReview(ctx, intakeDoc, creatorID, "author", "Author"); err != nil {
t.Fatalf("submit intake: %v", err)
}
if _, err := docEngine.ReviewApprove(ctx, intakeDoc, reviewerID, "reviewer", "Reviewer", "ok"); err != nil {
t.Fatalf("review intake: %v", err)
}
if _, err := docEngine.ApproverApprove(ctx, intakeDoc, approverID, "approver", "Approver", "ok"); err != nil {
t.Fatalf("approve intake: %v", err)
}
t.Log("✓ [2. full intake] cross-user approve complete")
advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision,
})
reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.1_triage/advance", instanceID), bytes.NewReader(advanceBody))
withBearerAuth(reqAdvance, creatorToken)
recAdvance := httptest.NewRecorder()
handler.ServeHTTP(recAdvance, reqAdvance)
if recAdvance.Code != http.StatusOK {
t.Fatalf("advance V0.1: %d %s", recAdvance.Code, recAdvance.Body.String())
}
t.Log("✓ [3. advance V0.1] ok")
researchDoc := runPI("research", []bizdev.MaterialInput{{
Filename: "research_scope.md",
Content: []byte("# Research none\n\n生成后不走人工审批\n"),
}})
// none: HTTP submit-review must be denied by duty gate (engine alone is not the gate)
submitBody, _ := json.Marshal(map[string]any{"doc_id": researchDoc})
reqSubmit := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBody))
withBearerAuth(reqSubmit, creatorToken)
recSubmit := httptest.NewRecorder()
handler.ServeHTTP(recSubmit, reqSubmit)
if recSubmit.Code == http.StatusOK {
t.Fatalf("none doc must reject submit-review, got 200: %s", recSubmit.Body.String())
}
if !bytes.Contains(recSubmit.Body.Bytes(), []byte("DOC_DUTY_NOT_MANAGED")) &&
!bytes.Contains(recSubmit.Body.Bytes(), []byte("未纳入人工审批")) &&
recSubmit.Code != http.StatusForbidden {
t.Fatalf("none submit want forbidden/duty-not-managed, got %d %s", recSubmit.Code, recSubmit.Body.String())
}
t.Logf("✓ [4. none research] generate ok; submit-review denied HTTP %d", recSubmit.Code)
reqReady := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.5_feasibility/readiness", instanceID), nil)
withBearerAuth(reqReady, creatorToken)
recReady := httptest.NewRecorder()
handler.ServeHTTP(recReady, reqReady)
if recReady.Code != http.StatusOK {
t.Fatalf("readiness: %d %s", recReady.Code, recReady.Body.String())
}
var readiness projectinstance.StageReadiness
if err := json.Unmarshal(recReady.Body.Bytes(), &readiness); err != nil {
t.Fatalf("decode readiness: %v body=%s", err, recReady.Body.String())
}
var researchItem *projectinstance.DocTaskReadiness
for i := range readiness.DocTasks {
if readiness.DocTasks[i].TaskKey == "research" {
researchItem = &readiness.DocTasks[i]
break
}
}
if researchItem == nil {
t.Fatalf("research missing in readiness: %+v", readiness)
}
if researchItem.ApprovalManaged || researchItem.Required {
t.Fatalf("none research must not be approval-managed/required: %+v", researchItem)
}
if !readiness.Ready {
t.Fatalf("stage must be ready without approving none doc: %+v", readiness)
}
t.Log("✓ [5. readiness] none does not block")
// Config revision may bump after V0.1 advance — reload instance.
reqInst := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workflow-instances/%d", instanceID), nil)
withBearerAuth(reqInst, creatorToken)
recInst := httptest.NewRecorder()
handler.ServeHTTP(recInst, reqInst)
if recInst.Code != http.StatusOK {
t.Fatalf("get instance: %d %s", recInst.Code, recInst.Body.String())
}
var detail struct {
Instance projectinstance.WorkflowInstance `json:"instance"`
}
if err := json.Unmarshal(recInst.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode instance: %v", err)
}
advanceBody2, _ := json.Marshal(projectinstance.AdvanceStageInput{
StageKey: "V0.5_feasibility", ExpectedConfigRevision: detail.Instance.ConfigRevision,
})
reqAdvance2 := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.5_feasibility/advance", instanceID), bytes.NewReader(advanceBody2))
withBearerAuth(reqAdvance2, creatorToken)
recAdvance2 := httptest.NewRecorder()
handler.ServeHTTP(recAdvance2, reqAdvance2)
if recAdvance2.Code != http.StatusOK {
t.Fatalf("advance V0.5: %d %s", recAdvance2.Code, recAdvance2.Body.String())
}
t.Log("✓ [6. advance V0.5] none non-blocking PASS")
// Refresh analogue: re-fetch settings after stage advance.
reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", projectID), nil)
withBearerAuth(reqSettings, creatorToken)
recSettings := httptest.NewRecorder()
handler.ServeHTTP(recSettings, reqSettings)
if recSettings.Code != http.StatusOK {
t.Fatalf("settings refresh: %d %s", recSettings.Code, recSettings.Body.String())
}
t.Log("✓ [7. refresh] settings still readable")
}