Absorb stalled capability fixes and avoid goose out-of-order on schema 57 DBs by shipping chat_summaries as 00058. Co-authored-by: Cursor <cursoragent@cursor.com>
400 lines
13 KiB
Go
400 lines
13 KiB
Go
package projectworkflow
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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")
|
|
}
|
|
}
|
|
|