package projectinstance import ( "context" "testing" "chat-agent-system/internal/workflowdef" ) func TestNarrativeGating_PendingStageDocsRunnable(t *testing.T) { ctx := context.Background() store := newMemStore() _ = SeedProjectMeta(store, 880, LifecyclePlanning, "", 1, 0) svc := NewService(store, twoStageResolver(workflowdef.StageGatingNarrative), nil, nil) activateTwoStage(t, svc, 880) forcePartialDuty(store, 880, "partial_doc") access, err := svc.ResolveStageAccess(ctx, 880, 99, false) if err != nil { t.Fatal(err) } if access.ActiveStageKey != "V0.1_triage" { t.Fatalf("active=%s", access.ActiveStageKey) } if !access.TaskKeyAllowed("research") { t.Fatalf("narrative must allow pending-stage research, %+v", access.AllowedTaskKeys) } if !access.StageKeyAllowed("V0.5_feasibility") { t.Fatal("narrative must open later stage keys") } if access.TaskKeyAllowed("disabled_doc") { t.Fatal("disabled docs stay out of AllowedTaskKeys") } if access.TaskKeyAllowed("partial_doc") { t.Fatal("partial duty stays out of AllowedTaskKeys") } run := svc.CheckDocTaskAccess(ctx, 880, 99, "research", DocAccessActionRun, false) if !run.Allowed { t.Fatalf("narrative run: %+v", run) } submit := svc.CheckDocTaskAccess(ctx, 880, 99, "research", DocAccessActionSubmit, false) if submit.Allowed || submit.Code != AccessCodeForbiddenDuty { t.Fatalf("narrative must keep duty on submit: %+v", submit) } authorSubmit := svc.CheckDocTaskAccess(ctx, 880, 1, "research", DocAccessActionSubmit, false) if !authorSubmit.Allowed { t.Fatalf("author submit: %+v", authorSubmit) } disabled := svc.CheckDocTaskAccess(ctx, 880, 1, "disabled_doc", DocAccessActionRun, true) if disabled.Allowed || disabled.Code != AccessCodeDocTaskDisabled { t.Fatalf("disabled: %+v", disabled) } partial := svc.CheckDocTaskAccess(ctx, 880, 1, "partial_doc", DocAccessActionEdit, true) if partial.Allowed || partial.Code != AccessCodeForbiddenDuty { t.Fatalf("partial: %+v", partial) } } func TestStrictGating_PendingStageDocsDenied(t *testing.T) { ctx := context.Background() store := newMemStore() _ = SeedProjectMeta(store, 881, LifecyclePlanning, "", 1, 0) svc := NewService(store, twoStageResolver(""), nil, nil) activateTwoStage(t, svc, 881) access, err := svc.ResolveStageAccess(ctx, 881, 99, false) if err != nil { t.Fatal(err) } if access.TaskKeyAllowed("research") { t.Fatal("strict must not run pending-stage research") } if !access.TaskKeyVisible("research") { t.Fatal("strict view exemption stays") } decision := svc.CheckDocTaskAccess(ctx, 881, 1, "research", DocAccessActionRun, true) if decision.Allowed || decision.Code != AccessCodeNotCurrentStage { t.Fatalf("strict run: %+v", decision) } view := svc.CheckDocTaskAccess(ctx, 881, 1, "research", DocAccessActionView, true) if !view.Allowed { t.Fatalf("strict view: %+v", view) } } func TestStampPlanStageGating_OmittedForStrictHash(t *testing.T) { version := &workflowdef.Version{Definition: workflowdef.VersionDefinition{}} plan := WorkflowPlan{WorkflowDefinitionKey: "main", WorkflowVersion: 1} stampPlanStageGating(&plan, version) if plan.StageGating != "" { t.Fatalf("strict stamp=%q", plan.StageGating) } h1, err := computePlanHash(plan) if err != nil { t.Fatal(err) } plan.StageGating = workflowdef.StageGatingStrict stampPlanStageGating(&plan, version) h2, err := computePlanHash(plan) if err != nil { t.Fatal(err) } if h1 != h2 { t.Fatalf("strict hash drifted %s vs %s", h1, h2) } version.Definition.StageGating = workflowdef.StageGatingNarrative stampPlanStageGating(&plan, version) if plan.StageGating != workflowdef.StageGatingNarrative { t.Fatalf("narrative stamp=%q", plan.StageGating) } } func twoStageResolver(gating string) *stubVersionResolver { r := testResolver() ver := r.versions[workflowdef.OfficialKeyMain] ver.Definition.StageGating = gating ver.Definition.Stages = []workflowdef.StageDef{ { Key: "V0.1_triage", Name: "V0.1 需求摸底", Position: 10, DocTasks: []workflowdef.DocTaskDef{{ Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表", }}, }, { Key: "V0.5_feasibility", Name: "V0.5 方案与可行性", Position: 20, DocTasks: []workflowdef.DocTaskDef{ { Key: "research", Title: "调研报告", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, OutputDocType: "research", OutputDocTitle: "调研报告", }, { Key: "disabled_doc", Title: "关闭文档", CreatorConfigurable: true, DefaultEnabled: false, OutputDocType: "disabled_doc", OutputDocTitle: "关闭文档", }, { Key: "partial_doc", Title: "半配置", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, OutputDocType: "partial_doc", OutputDocTitle: "半配置", }, }, }, } return r } func activateTwoStage(t *testing.T, svc *Service, projectID int64) { t.Helper() full := DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} plan := WorkflowPlan{ WorkflowDefinitionKey: workflowdef.OfficialKeyMain, WorkflowVersion: 1, Stages: []StagePlan{ { StageKey: "V0.1_triage", Enabled: true, DocTasks: []DocTaskPlan{{TaskKey: "intake", Enabled: true, Duties: full}}, }, { StageKey: "V0.5_feasibility", Enabled: true, DocTasks: []DocTaskPlan{ {TaskKey: "research", Enabled: true, Duties: full}, {TaskKey: "disabled_doc", Enabled: false, Duties: full}, {TaskKey: "partial_doc", Enabled: true, Duties: full}, }, }, }, } if _, err := svc.SaveWorkflowPlan(context.Background(), projectID, 1, true, SavePlanInput{ Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, }); err != nil { t.Fatalf("save: %v", err) } preview, err := svc.ActivationPreview(context.Background(), projectID, 1, true) if err != nil { t.Fatalf("preview: %v", err) } if _, err := svc.Activate(context.Background(), projectID, 1, true, ActivateInput{ ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, ExpectedPlanRevision: preview.ExpectedPlanRevision, }); err != nil { t.Fatalf("activate: %v", err) } } func forcePartialDuty(store *memStore, projectID int64, taskKey string) { store.mu.Lock() defer store.mu.Unlock() inst := store.live[projectID] for i := range inst.ConfigSnapshot.Stages { for j := range inst.ConfigSnapshot.Stages[i].DocTasks { dt := &inst.ConfigSnapshot.Stages[i].DocTasks[j] if dt.TaskKey == taskKey { dt.Duties = DocDuty{AuthorUserID: 1} } } } }