test(projectinstance): pin duty mode and activation governance
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user