package projectworkflow import ( "context" "errors" "strings" "testing" "unicode/utf8" "chat-agent-system/internal/auth" ) func TestGenerateProjectCode_ValidUTF8ForChineseName(t *testing.T) { longChinese := "数字化高频超声手术吸引系统治理链测试项目名称" code := generateProjectCode(longChinese) if !utf8.ValidString(code) { t.Fatalf("project code must be valid UTF-8, got %q bytes=% x", code, []byte(code)) } if code == "" { t.Fatal("expected non-empty code") } } func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { ctx := context.Background() store := NewStore(nil) catalog := NewDefaultCatalog() svc := NewService(store, catalog) creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"} otherUser := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"} // 1. 未认证创建应被拦截 _, err := svc.CreateProject(ctx, nil, CreateProjectInput{Name: "测试项目A"}) if err != ErrUnauthenticated { t.Fatalf("expected ErrUnauthenticated, got: %v", err) } // 2. 正常创建 proj, err := svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "神经脉冲射频仪 (NeuroPulse)", DeviceType: "高频神经射频治疗设备", IntendedUse: "用于中枢神经阻滞与射频毁损治疗", TargetMarket: "NMPA Class III", }) if err != nil { t.Fatalf("CreateProject failed: %v", err) } if proj.CreatorUserID != creator.ID { t.Errorf("expected CreatorUserID %d, got %d", creator.ID, proj.CreatorUserID) } if proj.LifecycleStatus != "planning" { t.Errorf("expected lifecycle 'planning', got '%s'", proj.LifecycleStatus) } if proj.ActiveWorkflowKey != "" { t.Errorf("expected empty active workflow in planning mode, got '%s'", proj.ActiveWorkflowKey) } if proj.WorkflowSelectionVersion != 1 { t.Errorf("expected initial version 1, got %d", proj.WorkflowSelectionVersion) } keyed, err := svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "带初始工作流键的规划项目", InitialWorkflowKey: "main", }) if err != nil { t.Fatalf("CreateProject with initial key: %v", err) } if keyed.LifecycleStatus != "planning" || keyed.ActiveWorkflowKey != "" { t.Fatalf("initial_workflow_key must not activate, got lifecycle=%s key=%s", keyed.LifecycleStatus, keyed.ActiveWorkflowKey) } keyedWFs, err := svc.ListProjectWorkflows(ctx, keyed.ID) if err != nil { t.Fatalf("list keyed workflows: %v", err) } if len(keyedWFs) != 6 { t.Fatalf("catalog must still mount after keyed create, got %d", len(keyedWFs)) } // 3. 验证自动挂载的工作流清单 wfs, err := svc.ListProjectWorkflows(ctx, proj.ID) if err != nil { t.Fatalf("ListProjectWorkflows failed: %v", err) } if len(wfs) != 6 { t.Fatalf("expected 6 default workflows, got %d", len(wfs)) } // 4. 非创建人尝试切换工作流 -> 必须被拒绝 _, err = svc.ActivateWorkflow(ctx, proj.ID, otherUser.ID, ActivateWorkflowInput{ WorkflowKey: "product_manager", ExpectedVersion: 1, Reason: "尝试越权切换", }) if err != ErrForbiddenNotCreator { t.Fatalf("expected ErrForbiddenNotCreator, got: %v", err) } // 5. 创建人正常切换工作流 -> 成功升级版本 res, err := svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{ WorkflowKey: "product_manager", ExpectedVersion: 1, Reason: "进入产品需求阶段", }) if err != nil { t.Fatalf("ActivateWorkflow failed: %v", err) } if res.ActiveWorkflowKey != "product_manager" { t.Errorf("expected active workflow 'product_manager', got '%s'", res.ActiveWorkflowKey) } if res.Version != 2 { t.Errorf("expected version 2 after switch, got %d", res.Version) } // 6. 并发冲突:使用旧版本 1 再次切换 -> 必须返回 ErrConcurrentConflict _, err = svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{ WorkflowKey: "test_manager", ExpectedVersion: 1, // 旧版本 Reason: "并发落后切换", }) if err != ErrConcurrentConflict { t.Fatalf("expected ErrConcurrentConflict, got: %v", err) } // 7. 幂等测试:重复激活当前工作流 'product_manager' resIdempotent, err := svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{ WorkflowKey: "product_manager", ExpectedVersion: 2, Reason: "重复激活", }) if err != nil { t.Fatalf("idempotent switch failed: %v", err) } if resIdempotent.ActiveWorkflowKey != "product_manager" { t.Errorf("expected active 'product_manager', got '%s'", resIdempotent.ActiveWorkflowKey) } // 8. 切换到不存在的工作流 -> 返回 ErrWorkflowNotFound _, err = svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{ WorkflowKey: "non_existent_wf", }) if err == nil || !errors.Is(err, ErrWorkflowNotFound) { t.Fatalf("expected ErrWorkflowNotFound, got: %v", err) } } 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) catalog := NewDefaultCatalog() svc := NewService(store, catalog) creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"} proj, _ := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "超声手术刀"}) // 记录测试经理方案依赖产品经理需求规格书 v1 err := svc.RecordLineage(ctx, &DocumentLineage{ ProjectID: proj.ID, DownstreamDocID: "doc_test_plan_001", DownstreamVersion: 1, UpstreamDocID: "doc_system_reqs_001", UpstreamVersion: 1, UpstreamSHA256: "hash_v1_aaa", }) if err != nil { t.Fatalf("RecordLineage failed: %v", err) } lineages, err := svc.GetLineageForDownstream(ctx, "doc_test_plan_001", 1) if err != nil || len(lineages) == 0 { t.Fatalf("expected lineage record, got err: %v, len: %d", err, len(lineages)) } if lineages[0].IsStale { t.Errorf("initial lineage should not be stale") } // 上游生成相同 SHA256 -> 不触发 stale staleUpdates, err := svc.CheckAndPropagateStale(ctx, "doc_system_reqs_001", "hash_v1_aaa", "") if err != nil { t.Fatalf("CheckAndPropagateStale failed: %v", err) } if len(staleUpdates) > 0 { t.Errorf("expected 0 stale updates for identical hash, got %d", len(staleUpdates)) } // 上游生成不同 SHA256 (内容更新) -> 触发下游 stale 传播 staleUpdates, err = svc.CheckAndPropagateStale(ctx, "doc_system_reqs_001", "hash_v2_bbb", "系统需求新增 RF-009 射频功率限制") if err != nil { t.Fatalf("CheckAndPropagateStale failed: %v", err) } if len(staleUpdates) != 1 { t.Fatalf("expected 1 stale update, got %d", len(staleUpdates)) } if !staleUpdates[0].IsStale { t.Errorf("lineage should be marked as stale") } if staleUpdates[0].DownstreamDocID != "doc_test_plan_001" { t.Errorf("expected downstream 'doc_test_plan_001', got '%s'", staleUpdates[0].DownstreamDocID) } } func TestGetLineageForProjectDownstream_ExcludesOtherProject(t *testing.T) { ctx := context.Background() svc := NewService(NewStore(nil), NewDefaultCatalog()) creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"} projA, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "项目A"}) if err != nil { t.Fatal(err) } projB, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "项目B"}) if err != nil { t.Fatal(err) } downstreamB := "doc_projB_test_manager" upstreamB := "doc_projB_system_reqs" if err := svc.RecordLineage(ctx, &DocumentLineage{ ProjectID: projB.ID, DownstreamDocID: downstreamB, DownstreamVersion: 1, UpstreamDocID: upstreamB, UpstreamVersion: 1, }); err != nil { t.Fatal(err) } fromA, err := svc.GetLineageForProjectDownstream(ctx, projA.ID, downstreamB, 0) if err != nil { t.Fatal(err) } if len(fromA) != 0 { t.Fatalf("project A must not see project B lineage, got %+v", fromA) } fromB, err := svc.GetLineageForProjectDownstream(ctx, projB.ID, downstreamB, 0) if err != nil { t.Fatal(err) } if len(fromB) != 1 || fromB[0].UpstreamDocID != upstreamB { t.Fatalf("project B should see its own edge, got %+v", fromB) } unscoped, err := svc.GetLineageForDownstream(ctx, downstreamB, 0) if err != nil || len(unscoped) != 1 { t.Fatalf("internal unscoped lookup remains for stale propagation, got %v %+v", err, unscoped) } } func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) { ctx := context.Background() svc := NewService(NewStore(nil), NewDefaultCatalog()) creator := &auth.UserAccount{ID: 201, Username: "dev", Name: "开发者"} proj, err := svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "Agent-System 自举", ProductDomain: "non_medical_software", ProjectTypeKey: "existing_self_bootstrap", ComplianceProfileKey: "internal_control+security_baseline", GovernanceProfile: "standard", TeamMode: "solo", ExecutionMode: "docs_only", ProblemStatement: "建立可信路线", TargetUsers: "内部团队", TargetOutcome: "可执行发布路线", InitialWorkflowKey: "software_bootstrap", }) if err != nil { t.Fatalf("CreateProject: %v", err) } if proj.ProductDomain != "non_medical_software" || proj.ProjectTypeKey != "existing_self_bootstrap" { t.Fatalf("domain/type not persisted: %+v", proj) } if proj.CurrentStage != "S0_boundary" { t.Fatalf("expected S0_boundary stage, got %s", proj.CurrentStage) } if proj.DeviceType != "" || proj.IntendedUse != "" { t.Fatalf("software create must not invent medical device fields: device=%q use=%q", proj.DeviceType, proj.IntendedUse) } if proj.ActiveWorkflowKey != "" { t.Fatal("must remain planning without activation") } wfs, err := svc.ListProjectWorkflows(ctx, proj.ID) if err != nil { t.Fatal(err) } got := map[string]bool{} for _, wf := range wfs { got[wf.WorkflowKey] = true if wf.Status == "active" { t.Fatalf("software create must not auto-activate %s", wf.WorkflowKey) } } if !got["software_bootstrap"] || !got["software_delivery"] { t.Fatalf("expected software keys, got %v", got) } if got["main"] || got["product_manager"] || got["test_manager"] || got["rdev"] { t.Fatalf("software project must not mount medical keys: %v", got) } med, err := svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "医疗回归", ProductDomain: "medical_device", DeviceType: "超声", }) if err != nil { t.Fatal(err) } mwfs, err := svc.ListProjectWorkflows(ctx, med.ID) if err != nil { t.Fatal(err) } mgot := map[string]bool{} for _, wf := range mwfs { mgot[wf.WorkflowKey] = true } if !mgot["main"] || !mgot["product_manager"] || !mgot["test_manager"] || !mgot["rdev"] { t.Fatalf("medical project must mount four medical keys: %v", mgot) } _, err = svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "bad", ProductDomain: "non_medical_software", ExecutionMode: "controlled_patch", }) if err == nil || !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected controlled_patch rejection, got %v", err) } } func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) { svc := NewService(newMemStore(), NewDefaultCatalog()) ctx := context.Background() creator := &auth.UserAccount{ID: 42, Username: "creator"} first, err := svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "幂等项目", ClientKey: "ck-same-1", }) if err != nil { t.Fatalf("first create: %v", err) } second, err := svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "幂等项目-改名也应返回同一条", ClientKey: "ck-same-1", }) if err != nil { t.Fatalf("idempotent create: %v", err) } if first.ID != second.ID { t.Fatalf("expected same project id, got %d vs %d", first.ID, second.ID) } _, err = svc.CreateProject(ctx, creator, CreateProjectInput{Name: "幂等项目"}) if err == nil || !errors.Is(err, ErrDuplicateProjectName) { t.Fatalf("expected ErrDuplicateProjectName, got %v", err) } member := &auth.UserAccount{ID: 99, Username: "other"} _, err = svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"}) if err == nil || !errors.Is(err, ErrDuplicateProjectName) { t.Fatalf("different creator same name must fail globally, got %v", err) } other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目-其他"}) if err != nil { t.Fatalf("different name should succeed: %v", err) } if other.ID == first.ID { t.Fatalf("different names must get distinct projects") } } // SWBOOT-10: controlled_patch creation requires an allowlisted repo binding; // checker nil or path not allowlisted keeps the fail-closed rejection. func TestCreateProject_ControlledPatchGating(t *testing.T) { store := NewStore(nil) svc := NewService(store, nil) mk := func(execMode, repo string) CreateProjectInput { return CreateProjectInput{ Name: "SWBOOT-10 门禁验证", ProductDomain: "non_medical_software", ProjectTypeKey: "greenfield", ExecutionMode: execMode, RepoPath: repo, } } user := &auth.UserAccount{ID: 42, Name: "t"} // fail-closed without checker if _, err := svc.CreateProject(context.Background(), user, mk("controlled_patch", "/repo/x")); err == nil || !strings.Contains(err.Error(), "controlled_patch") { t.Fatalf("expected rejection without checker: %v", err) } // checker rejecting the path svc.RepoAllowlistChecker = func(p string) bool { return p == "/allow/repo" } if _, err := svc.CreateProject(context.Background(), user, mk("controlled_patch", "/repo/x")); err == nil { t.Fatal("expected rejection for non-allowlisted path") } // checker passing + repo path -> created and binding persisted created, err := svc.CreateProject(context.Background(), user, mk("controlled_patch", "/allow/repo")) if err != nil || created == nil { t.Fatalf("expected controlled_patch creation with allowlisted binding: %v", err) } // docs_only projects need no repo binding (unchanged default path) docsInput := mk("docs_only", "") docsInput.Name = "SWBOOT-10 门禁验证-文档型" if _, err := svc.CreateProject(context.Background(), user, docsInput); err != nil { t.Fatalf("docs_only must not require binding: %v", err) } }