From de66558736e4110833a3b19467bba63a419eee3b Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 06:53:21 +0000 Subject: [PATCH] test(projectinstance): pin duty mode and activation governance Co-authored-by: Cursor --- internal/projectinstance/duty_mode_test.go | 108 +++++++++++++ internal/projectinstance/service_test.go | 168 ++++++++++++++++++++- internal/projectinstance/testutil.go | 36 +++++ internal/projectinstance/validator_test.go | 28 ++++ 4 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 internal/projectinstance/duty_mode_test.go diff --git a/internal/projectinstance/duty_mode_test.go b/internal/projectinstance/duty_mode_test.go new file mode 100644 index 00000000..349472cd --- /dev/null +++ b/internal/projectinstance/duty_mode_test.go @@ -0,0 +1,108 @@ +package projectinstance + +import ( + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestClassifyDocDuty(t *testing.T) { + t.Parallel() + cases := []struct { + name string + duty DocDuty + want DutyMode + }{ + {name: "all_zero", duty: DocDuty{}, want: DutyModeNone}, + {name: "negative", duty: DocDuty{AuthorUserID: -1}, want: DutyModePartial}, + {name: "one_role", duty: DocDuty{AuthorUserID: 9}, want: DutyModePartial}, + {name: "two_roles", duty: DocDuty{AuthorUserID: 9, ReviewerUserID: 2}, want: DutyModePartial}, + {name: "full_distinct", duty: DocDuty{AuthorUserID: 9, ReviewerUserID: 2, ApproverUserID: 3}, want: DutyModeFull}, + {name: "full_duplicate", duty: DocDuty{AuthorUserID: 9, ReviewerUserID: 9, ApproverUserID: 3}, want: DutyModeFull}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := classifyDocDuty(tc.duty); got != tc.want { + t.Fatalf("classifyDocDuty(%+v)=%s want %s", tc.duty, got, tc.want) + } + }) + } +} + +func TestDocTaskRequiresDuty(t *testing.T) { + t.Parallel() + if docTaskRequiresDuty(workflowdef.DocTaskDef{CreatorConfigurable: true}) { + t.Fatal("configurable optional doc must not require duties") + } + if !docTaskRequiresDuty(workflowdef.DocTaskDef{Required: true, CreatorConfigurable: true}) { + t.Fatal("required doc must keep duties") + } + if !docTaskRequiresDuty(workflowdef.DocTaskDef{Blocking: true, CreatorConfigurable: true}) { + t.Fatal("blocking doc must keep duties") + } + if !docTaskRequiresDuty(workflowdef.DocTaskDef{Required: true}) { + t.Fatal("non-configurable required doc must keep duties") + } +} + +func TestNormalizeDocTaskDuties(t *testing.T) { + t.Parallel() + cleared := normalizeDocTaskDuties(DocTaskPlan{ + Enabled: false, + Duties: DocDuty{AuthorUserID: 9, ReviewerUserID: 2, ApproverUserID: 3}, + }) + if cleared.Duties != (DocDuty{}) { + t.Fatalf("disabled task must drop leftover duties, got %+v", cleared.Duties) + } + none := normalizeDocTaskDuties(DocTaskPlan{Enabled: true, Duties: DocDuty{AuthorUserID: 0, ReviewerUserID: -0}}) + if none.Duties != (DocDuty{}) { + t.Fatalf("none must canonicalize to zero duties, got %+v", none.Duties) + } + partial := DocTaskPlan{Enabled: true, Duties: DocDuty{AuthorUserID: 9}} + if got := normalizeDocTaskDuties(partial); got.Duties.AuthorUserID != 9 { + t.Fatalf("partial must not auto-clear, got %+v", got.Duties) + } +} + +func TestValidateDocTaskDuties(t *testing.T) { + t.Parallel() + optional := workflowdef.DocTaskDef{Key: "research", Title: "调研", CreatorConfigurable: true} + required := workflowdef.DocTaskDef{Key: "intake", Title: "需求", Required: true} + + if issues := validateDocTaskDuties(DocTaskPlan{TaskKey: "research", Enabled: true}, optional); len(issues) != 0 { + t.Fatalf("optional none should pass, got %+v", issues) + } + reqIssues := validateDocTaskDuties(DocTaskPlan{TaskKey: "intake", TaskTitle: "需求", Enabled: true}, required) + if len(reqIssues) != 1 || reqIssues[0].Code != issueDutyAssignmentRequired || reqIssues[0].Field != "intake.duties" { + t.Fatalf("required none: %+v", reqIssues) + } + partial := validateDocTaskDuties(DocTaskPlan{ + TaskKey: "research", Enabled: true, Duties: DocDuty{AuthorUserID: 1}, + }, optional) + if len(partial) != 1 || partial[0].Code != issueDutyPartialAssignment || partial[0].Field != "research.duties" { + t.Fatalf("partial field must be exact, got %+v", partial) + } + sep := validateDocTaskDuties(DocTaskPlan{ + TaskKey: "intake", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 1, ApproverUserID: 2}, + }, required) + if len(sep) != 1 || sep[0].Code != issueSeparationOfDutyViolation { + t.Fatalf("duplicate full must keep SEPARATION_OF_DUTY_VIOLATION, got %+v", sep) + } +} + +func TestCountFullDutyDocs(t *testing.T) { + t.Parallel() + plan := WorkflowPlan{Stages: []StagePlan{{ + Enabled: true, + DocTasks: []DocTaskPlan{ + {Enabled: true, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}}, + {Enabled: true}, + {Enabled: false, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}}, + }, + }}} + if n := countFullDutyDocs(plan); n != 1 { + t.Fatalf("countFullDutyDocs=%d want 1", n) + } +} diff --git a/internal/projectinstance/service_test.go b/internal/projectinstance/service_test.go index c7ba5170..43c6eb12 100644 --- a/internal/projectinstance/service_test.go +++ b/internal/projectinstance/service_test.go @@ -79,7 +79,7 @@ func TestService_SavePlanReturnsValidationIssues(t *testing.T) { _ = SeedProjectMeta(store, 8, LifecyclePlanning, "", 1, 0) svc := NewService(store, testResolver(), nil, nil) bad := validPlan() - bad.Stages[0].DocTasks[0].Duties = DocDuty{} + bad.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: 1} _, err := svc.SaveWorkflowPlan(ctx, 8, 1, true, SavePlanInput{ Plan: bad, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, @@ -88,7 +88,169 @@ func TestService_SavePlanReturnsValidationIssues(t *testing.T) { 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) + if len(validationErr.Issues) != 1 || validationErr.Issues[0].Code != issueDutyPartialAssignment { + t.Fatalf("expected DUTY_PARTIAL_ASSIGNMENT, got %+v", validationErr.Issues) } } + +type mapMemberLookup struct { + ids map[int64]struct{} +} + +func (m *mapMemberLookup) ActiveMemberIDs(_ context.Context, _ int64) (map[int64]struct{}, error) { + out := make(map[int64]struct{}, len(m.ids)) + for id := range m.ids { + out[id] = struct{}{} + } + return out, nil +} + +func (m *mapMemberLookup) remove(id int64) { + delete(m.ids, id) +} + +func TestService_OptionalNoneSaveAndZeroFullActivate(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 81, LifecyclePlanning, "", 1, 0) + svc := NewService(store, optionalDutyResolver(), nil, nil) + + plan := optionalDutyPlan(DocDuty{}, DocDuty{}) + settings, err := svc.SaveWorkflowPlan(ctx, 81, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("zero full should save: %v", err) + } + if settings.Plan == nil || countFullDutyDocs(*settings.Plan) != 0 { + t.Fatalf("saved plan should stay ungoverned, got %+v", settings.Plan) + } + _, err = svc.ActivationPreview(ctx, 81, 1, true) + var previewErr *ValidationError + if !errors.As(err, &previewErr) || !hasIssue(previewErr.Issues, issueNoGovernedDocument) { + t.Fatalf("preview must reject zero governance, got %v", err) + } +} + +func TestService_RequiredEmptyDutiesRejected(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 82, LifecyclePlanning, "", 1, 0) + svc := NewService(store, requiredDutyResolver(), nil, nil) + plan := validPlan() + plan.Stages[0].DocTasks[0].Duties = DocDuty{} + _, err := svc.SaveWorkflowPlan(ctx, 82, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + var validationErr *ValidationError + if !errors.As(err, &validationErr) || !hasIssue(validationErr.Issues, issueDutyAssignmentRequired) { + t.Fatalf("required empty duties: %v", err) + } +} + +func TestService_InactiveMemberRejected(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 83, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}, 2: {}}}) + _, err := svc.SaveWorkflowPlan(ctx, 83, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + var validationErr *ValidationError + if !errors.As(err, &validationErr) || !hasIssue(validationErr.Issues, issueDutyAssigneeNotActive) { + t.Fatalf("inactive member: %v", err) + } +} + +func TestService_ActivateRechecksMembersAfterPreview(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 84, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + lookup := &mapMemberLookup{ids: map[int64]struct{}{1: {}, 2: {}, 3: {}}} + svc.SetActiveMemberLookup(lookup) + + if _, err := svc.SaveWorkflowPlan(ctx, 84, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 84, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + lookup.remove(3) + _, err = svc.Activate(ctx, 84, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + var validationErr *ValidationError + if !errors.As(err, &validationErr) || !hasIssue(validationErr.Issues, issueDutyAssigneeNotActive) { + t.Fatalf("activate after member left: %v", err) + } + _, reuseErr := svc.Activate(ctx, 84, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if reuseErr == nil { + t.Fatal("stale token must not skip the failed membership recheck") + } +} + +func TestService_FullPlusNoneActivateAndNormalize(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 85, LifecyclePlanning, "", 1, 0) + svc := NewService(store, optionalDutyResolver(), nil, nil) + svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}, 2: {}, 3: {}}}) + + disabled := optionalDutyPlan( + DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + DocDuty{AuthorUserID: 9, ReviewerUserID: 8, ApproverUserID: 7}, + ) + disabled.Stages[0].DocTasks[1].Enabled = false + + saved, err := svc.SaveWorkflowPlan(ctx, 85, 1, true, SavePlanInput{ + Plan: disabled, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("save disabled leftover: %v", err) + } + if saved.Plan.Stages[0].DocTasks[1].Duties != (DocDuty{}) { + t.Fatalf("disabled snapshot must drop old IDs, got %+v", saved.Plan.Stages[0].DocTasks[1].Duties) + } + + fullNone := optionalDutyPlan( + DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + DocDuty{}, + ) + saved, err = svc.SaveWorkflowPlan(ctx, 85, 1, true, SavePlanInput{ + Plan: fullNone, ExpectedPlanRevision: saved.WorkflowPlanRevision, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("save full+none: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 85, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + if _, err := svc.Activate(ctx, 85, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate full+none: %v", err) + } +} + +func hasIssue(issues []workflowdef.ValidationIssue, code string) bool { + for _, issue := range issues { + if issue.Code == code { + return true + } + } + return false +} diff --git a/internal/projectinstance/testutil.go b/internal/projectinstance/testutil.go index 9e6519d8..17ee259d 100644 --- a/internal/projectinstance/testutil.go +++ b/internal/projectinstance/testutil.go @@ -101,6 +101,42 @@ func testResolver() *stubVersionResolver { } } +func optionalDutyResolver() *stubVersionResolver { + r := testResolver() + r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks = []workflowdef.DocTaskDef{ + { + Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表", + }, + { + Key: "research", Title: "市场调研", CreatorConfigurable: true, BlockingWhenEnabled: false, DefaultEnabled: true, + OutputDocType: "research", OutputDocTitle: "市场调研", + }, + } + return r +} + +func requiredDutyResolver() *stubVersionResolver { + r := testResolver() + r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks[0].Required = true + r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks[0].CreatorConfigurable = false + return r +} + +func optionalDutyPlan(intake, research DocDuty) WorkflowPlan { + return WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, + WorkflowVersion: 1, + Stages: []StagePlan{{ + StageKey: "V0.1_triage", Enabled: true, + DocTasks: []DocTaskPlan{ + {TaskKey: "intake", Enabled: true, Duties: intake}, + {TaskKey: "research", Enabled: true, Duties: research}, + }, + }}, + } +} + func validPlan() WorkflowPlan { return WorkflowPlan{ WorkflowDefinitionKey: workflowdef.OfficialKeyMain, diff --git a/internal/projectinstance/validator_test.go b/internal/projectinstance/validator_test.go index ebe34be4..2f020c49 100644 --- a/internal/projectinstance/validator_test.go +++ b/internal/projectinstance/validator_test.go @@ -55,6 +55,34 @@ func TestBuildDefaultPlan_IncludesDisplayNames(t *testing.T) { } } +func TestValidateActivationPlan_NoGovernedDocument(t *testing.T) { + version := optionalDutyResolver().versions[workflowdef.OfficialKeyMain] + plan := optionalDutyPlan(DocDuty{}, DocDuty{}) + if res := validatePlan(plan, version); !res.Valid { + t.Fatalf("ungoverned optional docs may be saved: %+v", res.Issues) + } + act := validateActivationPlan(plan, version) + if act.Valid || !hasIssue(act.Issues, issueNoGovernedDocument) { + t.Fatalf("activation must require one full duty doc, got %+v", act.Issues) + } +} + +func TestValidatePlan_RequiredAndPartial(t *testing.T) { + version := requiredDutyResolver().versions[workflowdef.OfficialKeyMain] + empty := validPlan() + empty.Stages[0].DocTasks[0].Duties = DocDuty{} + res := validatePlan(empty, version) + if res.Valid || !hasIssue(res.Issues, issueDutyAssignmentRequired) { + t.Fatalf("required empty: %+v", res.Issues) + } + partial := validPlan() + partial.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2} + res = validatePlan(partial, testResolver().versions[workflowdef.OfficialKeyMain]) + if res.Valid || !hasIssue(res.Issues, issueDutyPartialAssignment) || res.Issues[0].Field != "intake.duties" { + t.Fatalf("partial field: %+v", res.Issues) + } +} + func TestApplyDefinitionLabels_OverridesClientText(t *testing.T) { version := testResolver().versions[workflowdef.OfficialKeyMain] plan := validPlan()