package projectinstance import ( "context" "errors" "testing" "chat-agent-system/internal/workflowdef" ) func TestService_PlanPreviewActivate(t *testing.T) { ctx := context.Background() store := newMemStore() _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) svc := NewService(store, testResolver(), nil, nil) settings, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{ Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, }) if err != nil { t.Fatalf("SaveWorkflowPlan: %v", err) } if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != workflowdef.OfficialKeyMain { t.Fatalf("expected saved plan, got %+v", settings.Plan) } preview, err := svc.ActivationPreview(ctx, 42, 1, true) if err != nil { t.Fatalf("ActivationPreview: %v", err) } if preview.ConfirmationToken == "" || preview.PlanHash == "" { t.Fatal("expected confirmation token and plan hash") } result, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, ExpectedPlanRevision: preview.ExpectedPlanRevision, }) if err != nil { t.Fatalf("Activate: %v", err) } if result.LifecycleStatus != LifecycleActive || result.ActiveWorkflowKey != workflowdef.OfficialKeyMain { t.Fatalf("unexpected activate result: %+v", result) } live, _ := store.HasLiveInstance(ctx, 42) if !live { t.Fatal("expected live instance after activation") } } func TestService_BlockPlanEditWhenActive(t *testing.T) { ctx := context.Background() store := newMemStore() _ = SeedProjectMeta(store, 7, LifecycleActive, "main", 2, 1) svc := NewService(store, testResolver(), nil, nil) _, err := svc.SaveWorkflowPlan(ctx, 7, 1, true, SavePlanInput{ Plan: validPlan(), ExpectedPlanRevision: 1, }) if !errors.Is(err, ErrPlanFrozen) { t.Fatalf("expected ErrPlanFrozen, got %v", err) } } func TestService_SeparationOfDutyValidation(t *testing.T) { bad := validPlan() bad.Stages[0].DocTasks[0].Duties.ReviewerUserID = 1 res := validatePlan(bad, testResolver().versions[workflowdef.OfficialKeyMain]) if res.Valid { t.Fatal("expected separation of duty violation") } } func TestService_SavePlanReturnsValidationIssues(t *testing.T) { ctx := context.Background() store := newMemStore() _ = SeedProjectMeta(store, 8, LifecyclePlanning, "", 1, 0) svc := NewService(store, testResolver(), nil, nil) bad := validPlan() bad.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: 1} _, err := svc.SaveWorkflowPlan(ctx, 8, 1, true, SavePlanInput{ Plan: bad, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, }) var validationErr *ValidationError if !errors.As(err, &validationErr) { t.Fatalf("expected ValidationError, got %v", err) } 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 }