diff --git a/internal/projectinstance/document_access_test.go b/internal/projectinstance/document_access_test.go index b7a259bf..728f779a 100644 --- a/internal/projectinstance/document_access_test.go +++ b/internal/projectinstance/document_access_test.go @@ -56,3 +56,50 @@ func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) { t.Fatalf("expected DOC_DUTY_FORBIDDEN, got %+v", decision) } } + +func TestCheckDocTaskAccess_UnmanagedDutyModes(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 402, LifecyclePlanning, "", 1, 0) + svc := NewService(store, optionalDutyResolver(), nil, nil) + plan := optionalDutyPlan( + DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + DocDuty{}, + ) + if _, err := svc.SaveWorkflowPlan(ctx, 402, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 402, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 402, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + view := svc.CheckDocTaskAccess(ctx, 402, 99, "research", DocAccessActionView, false) + if !view.Allowed { + t.Fatalf("member view on none must allow, got %+v", view) + } + runMember := svc.CheckDocTaskAccess(ctx, 402, 99, "research", DocAccessActionRun, false) + if runMember.Allowed || runMember.Code != AccessCodeForbiddenDuty { + t.Fatalf("member run on none must deny, got %+v", runMember) + } + runCreator := svc.CheckDocTaskAccess(ctx, 402, 1, "research", DocAccessActionRun, true) + if !runCreator.Allowed { + t.Fatalf("creator run on none must allow, got %+v", runCreator) + } + review := svc.CheckDocTaskAccess(ctx, 402, 1, "research", DocAccessActionReview, true) + if review.Allowed || review.Code != AccessCodeDutyNotManaged { + t.Fatalf("review on none must be DOC_DUTY_NOT_MANAGED, got %+v", review) + } + approve := svc.CheckDocTaskAccess(ctx, 402, 1, "research", DocAccessActionApprove, true) + if approve.Allowed || approve.Code != AccessCodeDutyNotManaged { + t.Fatalf("approve on none must be DOC_DUTY_NOT_MANAGED, got %+v", approve) + } +} diff --git a/internal/projectinstance/gate_test.go b/internal/projectinstance/gate_test.go new file mode 100644 index 00000000..be9406e4 --- /dev/null +++ b/internal/projectinstance/gate_test.go @@ -0,0 +1,58 @@ +package projectinstance + +import ( + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestEvaluateStageReadiness_DutyModes(t *testing.T) { + t.Parallel() + stageDef := &workflowdef.StageDef{ + Key: "V0.1_triage", + DocTasks: []workflowdef.DocTaskDef{ + {Key: "intake", BlockingWhenEnabled: true, CreatorConfigurable: true}, + {Key: "research", CreatorConfigurable: true}, + }, + } + plan := StagePlan{ + StageKey: "V0.1_triage", + Enabled: true, + DocTasks: []DocTaskPlan{ + {TaskKey: "intake", Enabled: true, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}}, + {TaskKey: "research", Enabled: true}, + {TaskKey: "skipped", Enabled: false, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}}, + }, + } + + ready, issues, items := evaluateStageReadiness(plan, stageDef, map[string]string{"research": "draft"}) + if ready { + t.Fatal("full blocking doc missing approval must keep the gate closed") + } + if len(issues) != 1 || issues[0].TaskKey != "intake" { + t.Fatalf("only intake should block, got %+v", issues) + } + + var research, intake *DocTaskReadiness + for i := range items { + switch items[i].TaskKey { + case "research": + research = &items[i] + case "intake": + intake = &items[i] + case "skipped": + t.Fatal("disabled docs must not participate") + } + } + if intake == nil || !intake.ApprovalManaged || !intake.Required { + t.Fatalf("full blocking doc: %+v", intake) + } + if research == nil || research.ApprovalManaged || research.Required || !research.Enabled { + t.Fatalf("none doc must stay enabled without approval management: %+v", research) + } + + ready, issues, _ = evaluateStageReadiness(plan, stageDef, map[string]string{"intake": "approved"}) + if !ready || len(issues) != 0 { + t.Fatalf("approved full + unmanaged none should be ready, issues=%+v", issues) + } +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index bd209d79..14142d0a 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -41,6 +41,43 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { } } +func TestResolveStageAccess_UnmanagedDocsStayVisible(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 43, LifecyclePlanning, "", 1, 0) + svc := NewService(store, optionalDutyResolver(), nil, nil) + if _, err := svc.SaveWorkflowPlan(ctx, 43, 1, true, SavePlanInput{ + Plan: optionalDutyPlan( + DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + DocDuty{}, + ), + ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 43, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 43, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + member, err := svc.ResolveStageAccess(ctx, 43, 99, false) + if err != nil { + t.Fatal(err) + } + if !member.StageKeyAllowed("V0.1_triage") || !member.TaskKeyAllowed("research") { + t.Fatalf("none docs must stay visible to members, got %+v", member) + } + if member.TaskKeyAllowed("intake") { + t.Fatal("members without intake duty must not see the governed task") + } +} + func TestResolveStageAccess_NoLiveInstanceUnrestricted(t *testing.T) { ctx := context.Background() store := newMemStore() diff --git a/internal/projectinstance/terminate_test.go b/internal/projectinstance/terminate_test.go index 0b68f4fd..570476d9 100644 --- a/internal/projectinstance/terminate_test.go +++ b/internal/projectinstance/terminate_test.go @@ -46,3 +46,39 @@ func TestTerminateInstance_UpdatesProjectLifecycle(t *testing.T) { t.Fatalf("expected planning with empty key, got lifecycle=%q key=%q", lifecycle, activeKey) } } + +func TestTerminateInstance_UnmanagedDocsFailClosed(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 301, LifecyclePlanning, "", 1, 0) + svc := NewService(store, optionalDutyResolver(), nil, &StubDocChecker{States: map[string]string{"intake": "approved"}}) + if _, err := svc.SaveWorkflowPlan(ctx, 301, 1, true, SavePlanInput{ + Plan: optionalDutyPlan( + DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + DocDuty{}, + ), + ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 301, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, 301, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + if _, err := svc.TerminateInstance(ctx, activated.Instance.ID, 301, 1, true, TerminateInput{ + Reason: "关闭含 none 文档的实例", Confirm: true, ExpectedConfigRevision: activated.Instance.ConfigRevision, + }); err != nil { + t.Fatalf("terminate: %v", err) + } + d := svc.CheckDocTaskAccess(ctx, 301, 1, "research", DocAccessActionView, true) + if d.Allowed || d.Code != AccessCodeInstanceClosed { + t.Fatalf("terminated none docs must fail closed, got %+v", d) + } +} diff --git a/internal/projectinstance/testutil.go b/internal/projectinstance/testutil.go index 17ee259d..559d6e59 100644 --- a/internal/projectinstance/testutil.go +++ b/internal/projectinstance/testutil.go @@ -101,6 +101,16 @@ func testResolver() *stubVersionResolver { } } +// OptionalDutyResolver 导出含可裁剪 research 文档的测试定义。 +func OptionalDutyResolver() VersionResolver { + return optionalDutyResolver() +} + +// OptionalDutyPlan 导出一份 full + none 测试规划。 +func OptionalDutyPlan(intake, research DocDuty) WorkflowPlan { + return optionalDutyPlan(intake, research) +} + func optionalDutyResolver() *stubVersionResolver { r := testResolver() r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks = []workflowdef.DocTaskDef{ diff --git a/internal/projectinstance/validator_test.go b/internal/projectinstance/validator_test.go index 2f020c49..f79cee3a 100644 --- a/internal/projectinstance/validator_test.go +++ b/internal/projectinstance/validator_test.go @@ -55,6 +55,21 @@ func TestBuildDefaultPlan_IncludesDisplayNames(t *testing.T) { } } +func TestPreviewSummary_SplitsGovernedAndUnmanaged(t *testing.T) { + version := optionalDutyResolver().versions[workflowdef.OfficialKeyMain] + plan := optionalDutyPlan( + DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + DocDuty{}, + ) + sum := previewSummary(plan, version) + if sum.EnabledDocTasks != 2 || sum.GovernedDocTasks != 1 || sum.UnmanagedDocTasks != 1 || sum.AssignedDuties != 3 { + t.Fatalf("preview counts: %+v", sum) + } + if len(sum.UnmanagedDocuments) != 1 || sum.UnmanagedDocuments[0].TaskKey != "research" { + t.Fatalf("unmanaged list: %+v", sum.UnmanagedDocuments) + } +} + func TestValidateActivationPlan_NoGovernedDocument(t *testing.T) { version := optionalDutyResolver().versions[workflowdef.OfficialKeyMain] plan := optionalDutyPlan(DocDuty{}, DocDuty{}) diff --git a/internal/workflow/runtime/orchestrator_test.go b/internal/workflow/runtime/orchestrator_test.go new file mode 100644 index 00000000..198a9970 --- /dev/null +++ b/internal/workflow/runtime/orchestrator_test.go @@ -0,0 +1,61 @@ +package runtime + +import ( + "context" + "testing" + + "chat-agent-system/internal/projectinstance" +) + +func TestResolveDocTask_NoneDutiesStayZero(t *testing.T) { + ctx := context.Background() + store := projectinstance.NewMemoryStore() + if err := projectinstance.SeedProjectMeta(store, 500, projectinstance.LifecyclePlanning, "", 1, 0); err != nil { + t.Fatal(err) + } + svc := projectinstance.NewService(store, projectinstance.OptionalDutyResolver(), nil, nil) + if _, err := svc.SaveWorkflowPlan(ctx, 500, 1, true, projectinstance.SavePlanInput{ + Plan: projectinstance.OptionalDutyPlan( + projectinstance.DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + projectinstance.DocDuty{}, + ), + ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 500, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 500, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + detail, _, err := svc.GetLiveInstanceDetail(ctx, 500) + if err != nil { + t.Fatal(err) + } + var researchID int64 + for _, st := range detail.Stages { + for _, dt := range st.DocTasks { + if dt.TaskKey == "research" { + researchID = dt.ID + } + } + } + if researchID == 0 { + t.Fatal("expected research doc task instance id") + } + + res, err := NewOrchestrator(svc).ResolveDocTask(ctx, 500, researchID) + if err != nil { + t.Fatalf("ResolveDocTask: %v", err) + } + if res.AuthorUserID != 0 || res.ReviewerUserID != 0 || res.ApproverUserID != 0 { + t.Fatalf("none duties must stay zero without creator fallback, got %+v", res) + } +}