From ad6e38d11afd13b45e479a201f496dad4a592a9a Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:47 +0000 Subject: [PATCH] test(workflow): add governance regression and activation PG coverage Cover public auth config, catalog policy, official main workflow activation on Postgres, and conn-busy metadata writeback fixes. Co-authored-by: Cursor --- internal/db/testdb.go | 29 ++++++ .../activation_official_pg_test.go | 99 +++++++++++++++++++ internal/projectinstance/approval_test.go | 13 ++- .../projectinstance/pg_integration_test.go | 6 +- internal/projectinstance/service_test.go | 46 ++++++--- internal/projectinstance/stage_access_test.go | 4 +- .../projectinstance/stage_service_test.go | 12 +-- internal/projectinstance/validator_test.go | 67 +++++++++++++ internal/projectworkflow/service_test.go | 33 +++++++ internal/server/auth_handlers_test.go | 36 +++++++ internal/server/project_handlers_test.go | 41 ++++++++ internal/server/project_workflow_api_test.go | 16 +-- .../server/workflow_governance_pg_test.go | 6 +- internal/workflowdef/catalog_policy_test.go | 43 ++++++++ internal/workflowdef/publish_test.go | 11 ++- 15 files changed, 415 insertions(+), 47 deletions(-) create mode 100644 internal/db/testdb.go create mode 100644 internal/projectinstance/activation_official_pg_test.go create mode 100644 internal/projectinstance/validator_test.go create mode 100644 internal/workflowdef/catalog_policy_test.go diff --git a/internal/db/testdb.go b/internal/db/testdb.go new file mode 100644 index 00000000..b71f192f --- /dev/null +++ b/internal/db/testdb.go @@ -0,0 +1,29 @@ +package db + +import ( + "os" + "strings" + "testing" +) + +const testDatabaseEnv = "TEST_DATABASE_URL" + +// RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。 +func RequireTestDatabaseURL(t *testing.T) string { + t.Helper() + dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) + if dsn == "" { + t.Fatalf("%s must be set for PostgreSQL integration tests (refusing default dev database)", testDatabaseEnv) + } + return dsn +} + +// SkipIfNoTestDatabaseURL 无隔离测试库时跳过(单元测试包内可选 PG 用例)。 +func SkipIfNoTestDatabaseURL(t *testing.T) string { + t.Helper() + dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) + if dsn == "" { + t.Skipf("%s not set; skipping PostgreSQL integration test", testDatabaseEnv) + } + return dsn +} diff --git a/internal/projectinstance/activation_official_pg_test.go b/internal/projectinstance/activation_official_pg_test.go new file mode 100644 index 00000000..55d848a1 --- /dev/null +++ b/internal/projectinstance/activation_official_pg_test.go @@ -0,0 +1,99 @@ +package projectinstance_test + +import ( + "context" + "testing" + "time" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" +) + +// TestPG_ActivateOfficialWorkflowRuntimeMetadata 回归激活链路: +// ActivateInstance.applyRuntimeMetadata 会遍历文档任务实例并逐条 UPDATE。 +// 若在同一事务连接上 rows 未读完时就执行 Exec,pgx 会返回 "conn busy"。 +// 本测试使用正式商务对接流程(3 阶段 7 任务)覆盖该路径。 +func TestPG_ActivateOfficialWorkflowRuntimeMetadata(t *testing.T) { + pool := openPGPool(t) + defer pool.Close() + ctx := context.Background() + + wfStore := workflowdef.NewStore(pool) + def, err := workflowdef.LoadManifestDefinition("main.v2.json") + if err != nil { + t.Fatalf("load main.v2 manifest: %v", err) + } + if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ + Key: workflowdef.OfficialKeyMain, + Name: "商务对接流程", + Definition: def, + }, 1); err != nil { + t.Fatalf("seed official main workflow: %v", err) + } + + pwStore := projectworkflow.NewStore(pool) + instStore := projectinstance.NewStore(pool) + svc := projectinstance.NewService(instStore, wfStore, pwStore, nil) + + var projectID int64 + code := "pg-official-" + time.Now().Format("150405.000") + err = pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) + VALUES ($1, 'PG Official Activation Test', 'active', 1, 'planning', '', 1, 0) RETURNING id + `, code).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + + plan, err := svc.BuildDefaultPlan(ctx, workflowdef.OfficialKeyMain, 0) + if err != nil { + t.Fatalf("default plan: %v", err) + } + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3, + } + } + } + + if _, err := svc.SaveWorkflowPlan(ctx, projectID, 1, true, projectinstance.SavePlanInput{ + Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save plan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, projectID, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, projectID, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + if activated.Instance.Status != projectinstance.InstanceActive { + t.Fatalf("expected active instance, got %q", activated.Instance.Status) + } + + // 运行时元数据应写回文档任务实例(回归 applyRuntimeMetadata 的逐条 UPDATE)。 + var executorKey string + err = pool.QueryRow(ctx, ` + SELECT dti.executor_key + FROM project_doc_task_instances dti + JOIN project_stage_instances si ON si.id = dti.stage_instance_id + WHERE si.workflow_instance_id = $1 AND dti.task_key = 'intake' + `, activated.Instance.ID).Scan(&executorKey) + if err != nil { + t.Fatalf("query runtime metadata: %v", err) + } + if executorKey != "bizdev.intake.v1" { + t.Fatalf("expected executor_key bizdev.intake.v1, got %q", executorKey) + } +} diff --git a/internal/projectinstance/approval_test.go b/internal/projectinstance/approval_test.go index 034b6c27..14dd1c43 100644 --- a/internal/projectinstance/approval_test.go +++ b/internal/projectinstance/approval_test.go @@ -3,19 +3,21 @@ package projectinstance import ( "context" "testing" + + "chat-agent-system/internal/workflowdef" ) func TestHandleDocumentApproved_StageReady(t *testing.T) { ctx := context.Background() store := newMemStore() - _ = SeedProjectMeta(store, 1, LifecycleActive, "medical_device_development", 1, 1) + _ = SeedProjectMeta(store, 1, LifecycleActive, workflowdef.OfficialKeyMain, 1, 1) svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) plan := WorkflowPlan{ - WorkflowDefinitionKey: "medical_device_development", + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, WorkflowVersion: 1, Stages: []StagePlan{{ - StageKey: "biz_intake", Enabled: true, + StageKey: "V0.1_triage", Enabled: true, DocTasks: []DocTaskPlan{{ TaskKey: "intake", Enabled: true, Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4}, @@ -26,7 +28,8 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) { ID: 1, ProjectID: 1, WorkflowVersionID: 10, Status: InstanceActive, ConfigRevision: 1, PlanHash: "h", ConfigSnapshot: plan, InstanceNo: 1, } - activated, err := store.ActivateInstance(ctx, 1, inst, "medical_device_development", 1, 2) + version := &workflowdef.Version{WorkflowKey: workflowdef.OfficialKeyMain, Version: 1} + activated, err := store.ActivateInstance(ctx, 1, inst, version, 1, 2) if err != nil { t.Fatalf("activate: %v", err) } @@ -38,7 +41,7 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) { if result == nil || !result.StageReady { t.Fatalf("expected stage ready, got %+v", result) } - if result.InstanceID != activated.ID || result.StageKey != "biz_intake" { + if result.InstanceID != activated.ID || result.StageKey != "V0.1_triage" { t.Fatalf("unexpected result: %+v", result) } } diff --git a/internal/projectinstance/pg_integration_test.go b/internal/projectinstance/pg_integration_test.go index b450cf1c..e0e71db4 100644 --- a/internal/projectinstance/pg_integration_test.go +++ b/internal/projectinstance/pg_integration_test.go @@ -2,7 +2,6 @@ package projectinstance_test import ( "context" - "os" "testing" "time" @@ -18,10 +17,7 @@ import ( func openPGPool(t *testing.T) *pgxpool.Pool { t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) diff --git a/internal/projectinstance/service_test.go b/internal/projectinstance/service_test.go index 355c6c46..75e26a7e 100644 --- a/internal/projectinstance/service_test.go +++ b/internal/projectinstance/service_test.go @@ -40,32 +40,36 @@ func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef. } func testResolver() *stubVersionResolver { + key := workflowdef.OfficialKeyMain ver := &workflowdef.Version{ ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished, - WorkflowKey: "medical_device_development", WorkflowName: "医疗器械开发", + WorkflowKey: key, WorkflowName: workflowdef.OfficialWorkflowName(key), Definition: workflowdef.VersionDefinition{ + SchemaVersion: 2, + WorkflowKey: key, Stages: []workflowdef.StageDef{{ - Key: "biz_intake", Name: "商务 intake", Optional: false, + Key: "V0.1_triage", Name: "V0.1 需求摸底", Optional: false, Position: 10, DocTasks: []workflowdef.DocTaskDef{{ - Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, + Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表", }}, }}, }, } return &stubVersionResolver{ defs: map[string]*workflowdef.Definition{ - "medical_device_development": {ID: 10, Key: "medical_device_development", Name: "医疗器械开发", LatestPublishedVersion: 1}, + key: {ID: 10, Key: key, Name: workflowdef.OfficialWorkflowName(key), LatestPublishedVersion: 1}, }, - versions: map[string]*workflowdef.Version{"medical_device_development": ver}, + versions: map[string]*workflowdef.Version{key: ver}, } } func validPlan() WorkflowPlan { return WorkflowPlan{ - WorkflowDefinitionKey: "medical_device_development", + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, WorkflowVersion: 1, Stages: []StagePlan{{ - StageKey: "biz_intake", Enabled: true, + StageKey: "V0.1_triage", Enabled: true, DocTasks: []DocTaskPlan{{ TaskKey: "intake", Enabled: true, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, @@ -86,7 +90,7 @@ func TestService_PlanPreviewActivate(t *testing.T) { if err != nil { t.Fatalf("SaveWorkflowPlan: %v", err) } - if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != "medical_device_development" { + if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != workflowdef.OfficialKeyMain { t.Fatalf("expected saved plan, got %+v", settings.Plan) } @@ -99,14 +103,14 @@ func TestService_PlanPreviewActivate(t *testing.T) { } result, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ - ConfirmationToken: preview.ConfirmationToken, + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, ExpectedPlanRevision: preview.ExpectedPlanRevision, }) if err != nil { t.Fatalf("Activate: %v", err) } - if result.LifecycleStatus != LifecycleActive || result.ActiveWorkflowKey != "medical_device_development" { + if result.LifecycleStatus != LifecycleActive || result.ActiveWorkflowKey != workflowdef.OfficialKeyMain { t.Fatalf("unexpected activate result: %+v", result) } @@ -133,8 +137,28 @@ func TestService_BlockPlanEditWhenActive(t *testing.T) { func TestService_SeparationOfDutyValidation(t *testing.T) { bad := validPlan() bad.Stages[0].DocTasks[0].Duties.ReviewerUserID = 1 - res := validatePlan(bad, testResolver().versions["medical_device_development"]) + res := validatePlan(bad, testResolver().versions[workflowdef.OfficialKeyMain]) if res.Valid { t.Fatal("expected separation of duty violation") } } + +func TestService_SavePlanReturnsValidationIssues(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 8, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + bad := validPlan() + bad.Stages[0].DocTasks[0].Duties = DocDuty{} + + _, err := svc.SaveWorkflowPlan(ctx, 8, 1, true, SavePlanInput{ + Plan: bad, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + var validationErr *ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected ValidationError, got %v", err) + } + if len(validationErr.Issues) != 3 { + t.Fatalf("expected three missing-duty issues, got %+v", validationErr.Issues) + } +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index 8b625179..1a740a46 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -48,12 +48,12 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { } creatorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, true) - if !creatorAccess.StageKeyAllowed("biz_intake") { + if !creatorAccess.StageKeyAllowed("V0.1_triage") { t.Fatal("creator should access active stage") } outsiderAccess, _ := svc.ResolveStageAccess(ctx, 42, 9, false) - if outsiderAccess.StageKeyAllowed("biz_intake") { + if outsiderAccess.StageKeyAllowed("V0.1_triage") { t.Fatal("user without duty should not access active stage chat") } } diff --git a/internal/projectinstance/stage_service_test.go b/internal/projectinstance/stage_service_test.go index 5418f775..75114df1 100644 --- a/internal/projectinstance/stage_service_test.go +++ b/internal/projectinstance/stage_service_test.go @@ -39,7 +39,7 @@ func TestService_AdvanceStageGate(t *testing.T) { t.Fatalf("Activate: %v", err) } - readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "biz_intake", 1, true) + readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "V0.1_triage", 1, true) if err != nil { t.Fatalf("GetStageReadiness: %v", err) } @@ -49,15 +49,15 @@ func TestService_AdvanceStageGate(t *testing.T) { _ = settings advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ - StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, }) if err != nil { t.Fatalf("AdvanceStage: %v", err) } - if advanced.CompletedStage != "biz_intake" { - t.Fatalf("expected completed biz_intake, got %s", advanced.CompletedStage) + if advanced.CompletedStage != "V0.1_triage" { + t.Fatalf("expected completed V0.1_triage, got %s", advanced.CompletedStage) } - if advanced.NextStageKey == "biz_intake" { + if advanced.NextStageKey == "V0.1_triage" { t.Fatal("next stage should not be same as completed") } } @@ -76,7 +76,7 @@ func TestService_AdvanceStageBlockedWithoutApproval(t *testing.T) { }) _, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ - StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, }) if !errors.Is(err, ErrStageGateNotReady) { t.Fatalf("expected ErrStageGateNotReady, got %v", err) diff --git a/internal/projectinstance/validator_test.go b/internal/projectinstance/validator_test.go new file mode 100644 index 00000000..ebe34be4 --- /dev/null +++ b/internal/projectinstance/validator_test.go @@ -0,0 +1,67 @@ +package projectinstance + +import ( + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestValidateActivationPlan_EmptyWorkflowBlocked(t *testing.T) { + plan := WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyProductManager, + WorkflowVersion: 2, + Stages: []StagePlan{{ + StageKey: "requirements_baseline", + Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "system_reqs", + Enabled: false, + }}, + }}, + } + version := &workflowdef.Version{ + Status: workflowdef.StatusPublished, + WorkflowKey: workflowdef.OfficialKeyProductManager, + Version: 2, + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 2, + Stages: []workflowdef.StageDef{{ + Key: "requirements_baseline", Position: 10, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "system_reqs", CreatorConfigurable: true, DefaultEnabled: true, + OutputDocType: "system_reqs", + }}, + }}, + }, + } + res := validatePlan(plan, version) + if !res.Valid { + t.Fatalf("save plan should allow all docs disabled: %+v", res.Issues) + } + act := validateActivationPlan(plan, version) + if act.Valid { + t.Fatal("activation should reject empty enabled doc set") + } +} + +func TestBuildDefaultPlan_IncludesDisplayNames(t *testing.T) { + version := testResolver().versions[workflowdef.OfficialKeyMain] + plan := buildDefaultPlan(version) + if len(plan.Stages) != 1 || plan.Stages[0].StageName != "V0.1 需求摸底" { + t.Fatalf("expected stage display name, got %+v", plan.Stages) + } + if len(plan.Stages[0].DocTasks) != 1 || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" { + t.Fatalf("expected task display title, got %+v", plan.Stages[0].DocTasks) + } +} + +func TestApplyDefinitionLabels_OverridesClientText(t *testing.T) { + version := testResolver().versions[workflowdef.OfficialKeyMain] + plan := validPlan() + plan.Stages[0].StageName = "客户端伪造阶段" + plan.Stages[0].DocTasks[0].TaskTitle = "客户端伪造文档" + applyDefinitionLabels(&plan, version) + if plan.Stages[0].StageName != "V0.1 需求摸底" || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" { + t.Fatalf("definition labels must win, got %+v", plan.Stages[0]) + } +} diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 9f821bf2..7016e764 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -125,6 +125,39 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { } } +func TestProjectWorkflowService_ListAllProjects(t *testing.T) { + ctx := context.Background() + svc := NewService(NewStore(nil), NewDefaultCatalog()) + creator := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"} + + created, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "管理员跨项目查看测试"}) + if err != nil { + t.Fatalf("CreateProject failed: %v", err) + } + visible, err := svc.ListProjects(ctx, 999) + if err != nil { + t.Fatalf("ListProjects failed: %v", err) + } + if len(visible) != 0 { + t.Fatalf("unrelated user should not see projects, got %d", len(visible)) + } + + all, err := svc.ListAllProjects(ctx) + if err != nil { + t.Fatalf("ListAllProjects failed: %v", err) + } + found := false + for _, project := range all { + if project.ID == created.ID { + found = true + break + } + } + if !found { + t.Fatalf("ListAllProjects should include project %d", created.ID) + } +} + func TestProjectWorkflowService_LineageAndStale(t *testing.T) { ctx := context.Background() store := NewStore(nil) diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index c7918a51..632548ae 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -25,6 +25,42 @@ func setupTestServer() (*httptest.Server, *auth.Service) { return ts, srv.AuthService() } +func TestAuthHandlers_PublicConfigUnauthenticated(t *testing.T) { + ts, authSvc := setupTestServer() + defer ts.Close() + + _ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{ + AllowRegistration: true, + RequireApproval: true, + }) + + resp, err := http.Get(ts.URL + "/api/v1/auth/public-config") + if err != nil { + t.Fatalf("public-config request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 for unauthenticated /auth/public-config, got %d", resp.StatusCode) + } + + var cfg auth.SystemAuthConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + t.Fatalf("decode public-config: %v", err) + } + if !cfg.AllowRegistration || !cfg.RequireApproval { + t.Fatalf("unexpected public-config: %+v", cfg) + } + + adminResp, err := http.Get(ts.URL + "/api/v1/admin/config") + if err != nil { + t.Fatalf("admin config request failed: %v", err) + } + defer adminResp.Body.Close() + if adminResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("Expected 401 for unauthenticated /admin/config, got %d", adminResp.StatusCode) + } +} + func TestAuthHandlers_UnauthenticatedMeReturns401(t *testing.T) { ts, _ := setupTestServer() defer ts.Close() diff --git a/internal/server/project_handlers_test.go b/internal/server/project_handlers_test.go index 02d34039..408c659c 100644 --- a/internal/server/project_handlers_test.go +++ b/internal/server/project_handlers_test.go @@ -9,8 +9,49 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" + "chat-agent-system/internal/projectworkflow" ) +func TestHandleListProjects_AdminCanSeeProjectsOwnedByOtherUsers(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + ctx := t.Context() + + creator, err := srv.AuthService().GetUserByID(ctx, 2) + if err != nil { + t.Fatalf("load creator: %v", err) + } + created, err := srv.pwSvc.CreateProject(ctx, creator, projectworkflow.CreateProjectInput{ + Name: "非管理员创建的项目", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + + admin, err := srv.AuthService().GetUserByID(ctx, 1) + if err != nil { + t.Fatalf("load admin: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil) + req = req.WithContext(withPrincipal(req.Context(), admin)) + rec := httptest.NewRecorder() + srv.handleListProjects(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var projects []*projectworkflow.Project + if err := json.NewDecoder(rec.Body).Decode(&projects); err != nil { + t.Fatalf("decode projects: %v", err) + } + for _, project := range projects { + if project.ID == created.ID { + return + } + } + t.Fatalf("admin response should include project %d", created.ID) +} + func TestHandleRunProjectWorkflow_DecodesBodyWithoutContentLength(t *testing.T) { cfg := &config.Config{ DataDir: t.TempDir(), diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index 353fc87d..edbe2d58 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -207,18 +207,18 @@ func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) { require.Positive(t, proj.ID) projectID := proj.ID - // 2. 在未批准系统需求规格书时,直接触发 test_manager -> 应触发前置阻断 400 / ERR_PRECONDITION_FAILED - bodyRunTestFail, _ := json.Marshal(map[string]any{ + // 2. 无已批准 SRS 时,test_manager 仍可独立启动(PM/TE 解耦) + bodyRunTestNoSRS, _ := 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(), "前置条件未满足") + reqRunTestNoSRS := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflows/test_manager/run", projectID), bytes.NewReader(bodyRunTestNoSRS)) + withUserIDAuth(reqRunTestNoSRS, loginTestUser(t, handler, "zhanggong", "123456")) + recRunTestNoSRS := httptest.NewRecorder() + handler.ServeHTTP(recRunTestNoSRS, reqRunTestNoSRS) + assert.Equal(t, http.StatusOK, recRunTestNoSRS.Code) + assert.NotContains(t, recRunTestNoSRS.Body.String(), "前置条件未满足") // 3. 在受控治理中创建并批准系统需求规格书 srDocID := fmt.Sprintf("doc_proj%d_system_reqs", projectID) diff --git a/internal/server/workflow_governance_pg_test.go b/internal/server/workflow_governance_pg_test.go index cdcc2793..51108f83 100644 --- a/internal/server/workflow_governance_pg_test.go +++ b/internal/server/workflow_governance_pg_test.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" "testing" "time" @@ -25,10 +24,7 @@ import ( func openPGPoolWithMigrations(t *testing.T) *pgxpool.Pool { t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) diff --git a/internal/workflowdef/catalog_policy_test.go b/internal/workflowdef/catalog_policy_test.go new file mode 100644 index 00000000..29d95504 --- /dev/null +++ b/internal/workflowdef/catalog_policy_test.go @@ -0,0 +1,43 @@ +package workflowdef + +import "testing" + +func TestOfficialWorkflowKeys(t *testing.T) { + keys := OfficialWorkflowKeys() + if len(keys) != 3 { + t.Fatalf("expected 3 official keys, got %d", len(keys)) + } + for _, k := range keys { + if !IsOfficialWorkflowKey(k) { + t.Fatalf("key %q not official", k) + } + } +} + +func TestValidateDefinitionV2_OfficialDocCounts(t *testing.T) { + def, err := LoadManifestDefinition("main.v2.json") + if err != nil { + t.Fatal(err) + } + normalizeManifestDefinition(&def) + res := ValidateDefinitionV2(def) + if !res.Valid { + t.Fatalf("main v2 invalid: %+v", res.Issues) + } +} + +func TestValidateDefinitionV2_RejectsUpstream(t *testing.T) { + def := VersionDefinition{ + SchemaVersion: 2, + WorkflowKey: OfficialKeyTestManager, + RequiredUpstreamWorkflow: "product_manager", + Stages: []StageDef{{ + Key: "s", Position: 10, + DocTasks: []DocTaskDef{{Key: "test_manager", OutputDocType: "test_manager", ExecutorKey: "squad.test_manager.v1"}}, + }}, + } + res := ValidateDefinitionV2(def) + if res.Valid { + t.Fatal("expected upstream rejection") + } +} diff --git a/internal/workflowdef/publish_test.go b/internal/workflowdef/publish_test.go index e3c239f1..1e7ef287 100644 --- a/internal/workflowdef/publish_test.go +++ b/internal/workflowdef/publish_test.go @@ -2,21 +2,18 @@ package workflowdef_test import ( "context" - "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" + "chat-agent-system/internal/db" "chat-agent-system/internal/workflowdef" ) func openPool(t *testing.T) *pgxpool.Pool { t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) @@ -43,6 +40,10 @@ func TestPublishVersion_ImmutableAfterPublish(t *testing.T) { ctx := context.Background() svc := workflowdef.NewService(pool, nil) key := "test_wf_" + time.Now().Format("150405") + defer func() { + _, _ = pool.Exec(ctx, `DELETE FROM workflow_versions WHERE workflow_id IN (SELECT id FROM workflow_definitions WHERE def_key = $1)`, key) + _, _ = pool.Exec(ctx, `DELETE FROM workflow_definitions WHERE def_key = $1`, key) + }() def, ver, err := svc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ Key: key, Name: "测试工作流", Definition: workflowdef.VersionDefinition{