New workflow instances store bd_* task keys, but chat still sends the old keys. Scope and document access now treat those aliases as the same task so a live instance no longer fails closed on an exact string miss. Co-authored-by: Cursor <cursoragent@cursor.com>
201 lines
6.8 KiB
Go
201 lines
6.8 KiB
Go
package projectinstance
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestResolveMessageScopeForTask_NeverActivatedPreActivation(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
_ = SeedProjectMeta(store, 6071, LifecyclePlanning, "", 86, 0)
|
|
svc := NewService(store, testResolver(), nil, nil)
|
|
|
|
scope, err := svc.ResolveMessageScopeForTask(ctx, 6071, "intake")
|
|
if err != nil {
|
|
t.Fatalf("never-activated must resolve: %v", err)
|
|
}
|
|
if scope == nil || scope.ScopeKind != ScopePreActivation {
|
|
t.Fatalf("want pre_activation, got %+v", scope)
|
|
}
|
|
if !MessageVisible(*scope, &StageAccess{Restricted: false}, 86, 86) {
|
|
t.Fatal("session owner must see pre_activation task messages")
|
|
}
|
|
if MessageVisible(*scope, &StageAccess{Restricted: false}, 86, 99) {
|
|
t.Fatal("non-owner must not see pre_activation task messages")
|
|
}
|
|
}
|
|
|
|
func TestResolveMessageScopeForTask_LiveStageScope(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
|
|
svc := NewService(store, testResolver(), nil, nil)
|
|
mustActivateTestPlan(t, svc, 42, 1)
|
|
|
|
scope, err := svc.ResolveMessageScopeForTask(ctx, 42, "intake")
|
|
if err != nil {
|
|
t.Fatalf("live resolve: %v", err)
|
|
}
|
|
if scope.ScopeKind != ScopeWorkflowStage || scope.StageKey == "" || scope.WorkflowInstanceID <= 0 {
|
|
t.Fatalf("want workflow_stage with ids, got %+v", scope)
|
|
}
|
|
}
|
|
|
|
func TestResolveMessageScopeForTask_ClosedNotPreActivation(t *testing.T) {
|
|
ctx := context.Background()
|
|
for _, status := range []string{InstanceCompleted, InstanceTerminated} {
|
|
t.Run(status, func(t *testing.T) {
|
|
store := newMemStore()
|
|
_ = SeedProjectMeta(store, 71, LifecyclePlanning, "", 1, 0)
|
|
svc := NewService(store, testResolver(), nil, nil)
|
|
mustActivateTestPlan(t, svc, 71, 1)
|
|
live, err := store.GetLiveInstance(ctx, 71)
|
|
if err != nil || live == nil {
|
|
t.Fatalf("live before close: %v", err)
|
|
}
|
|
live.Status = status
|
|
ms := store
|
|
ms.mu.Lock()
|
|
ms.live[71] = live
|
|
ms.mu.Unlock()
|
|
|
|
// access gate 先拒
|
|
d := svc.CheckDocTaskAccess(ctx, 71, 1, "intake", DocAccessActionRun, true)
|
|
if d.Allowed || d.Code != AccessCodeInstanceClosed {
|
|
t.Fatalf("access gate must close, got %+v", d)
|
|
}
|
|
// 直接调用层也不得当预激活
|
|
_, scopeErr := svc.ResolveMessageScopeForTask(ctx, 71, "intake")
|
|
if scopeErr == nil || !errors.Is(scopeErr, ErrTaskScopeNotResolved) {
|
|
t.Fatalf("closed must ErrTaskScopeNotResolved, got %v", scopeErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveMessageScopeForTask_StorageErrorPropagates(t *testing.T) {
|
|
want := errors.New("db down")
|
|
svc := NewService(&errGoverningStore{err: want}, testResolver(), nil, nil)
|
|
_, err := svc.ResolveMessageScopeForTask(context.Background(), 1, "intake")
|
|
if err == nil || !errors.Is(err, want) {
|
|
t.Fatalf("want storage error %v, got %v", want, err)
|
|
}
|
|
}
|
|
|
|
func TestMessageVisible_PreActivationOwnerOnly(t *testing.T) {
|
|
access := &StageAccess{Restricted: true, AllowedStageKeys: map[string]bool{"V0.1_triage": true}}
|
|
scope := MessageScope{ScopeKind: ScopePreActivation}
|
|
|
|
if !MessageVisible(scope, access, 9, 9) {
|
|
t.Fatal("owner should see pre_activation messages")
|
|
}
|
|
if MessageVisible(scope, access, 9, 99) {
|
|
t.Fatal("non-owner must not see pre_activation messages")
|
|
}
|
|
}
|
|
|
|
func TestMessageVisible_WorkflowStageRestricted(t *testing.T) {
|
|
access := &StageAccess{
|
|
Restricted: true,
|
|
AllowedStageKeys: map[string]bool{"V0.1_triage": true},
|
|
}
|
|
scope := MessageScope{ScopeKind: ScopeWorkflowStage, StageKey: "V0.1_triage"}
|
|
if !MessageVisible(scope, access, 9, 9) {
|
|
t.Fatal("allowed stage should be visible")
|
|
}
|
|
scope.StageKey = "V0.5_feasibility"
|
|
if MessageVisible(scope, access, 9, 9) {
|
|
t.Fatal("disallowed stage must be hidden")
|
|
}
|
|
}
|
|
|
|
func TestMessageVisible_LegacyUnscopedOwnerWhenRestricted(t *testing.T) {
|
|
access := &StageAccess{Restricted: true}
|
|
scope := MessageScope{ScopeKind: ScopeLegacyUnscoped}
|
|
if !MessageVisible(scope, access, 9, 9) {
|
|
t.Fatal("owner should see legacy_unscoped when restricted")
|
|
}
|
|
if MessageVisible(scope, access, 9, 99) {
|
|
t.Fatal("non-owner must not see legacy_unscoped when restricted")
|
|
}
|
|
}
|
|
|
|
func TestResolveMessageScopeForTask_LegacyKeyMatchesCanonicalSnapshot(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
|
|
svc := NewService(store, testResolver(), nil, nil)
|
|
mustActivateTestPlan(t, svc, 42, 1)
|
|
|
|
store.mu.Lock()
|
|
live := store.live[42]
|
|
live.ConfigSnapshot.Stages[0].DocTasks[0].TaskKey = "bd_intake"
|
|
for i := range store.stages[live.ID] {
|
|
for j := range store.stages[live.ID][i].tasks {
|
|
if store.stages[live.ID][i].tasks[j].taskKey == "intake" {
|
|
store.stages[live.ID][i].tasks[j].taskKey = "bd_intake"
|
|
}
|
|
}
|
|
}
|
|
store.mu.Unlock()
|
|
|
|
scope, err := svc.ResolveMessageScopeForTask(ctx, 42, "intake")
|
|
if err != nil {
|
|
t.Fatalf("legacy intake must resolve against bd_intake snapshot: %v", err)
|
|
}
|
|
if scope.ScopeKind != ScopeWorkflowStage || scope.StageKey != "V0.1_triage" {
|
|
t.Fatalf("want V0.1_triage workflow_stage, got %+v", scope)
|
|
}
|
|
}
|
|
|
|
func TestResolveMessageScopeForTask_CanonicalAliasAmbiguousAcrossStages(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
_ = SeedProjectMeta(store, 43, LifecyclePlanning, "", 1, 0)
|
|
svc := NewService(store, testResolver(), nil, nil)
|
|
mustActivateTestPlan(t, svc, 43, 1)
|
|
|
|
store.mu.Lock()
|
|
live := store.live[43]
|
|
live.ConfigSnapshot.Stages = append(live.ConfigSnapshot.Stages, StagePlan{
|
|
StageKey: "V0.5_feasibility", Enabled: true,
|
|
DocTasks: []DocTaskPlan{{TaskKey: "bd_intake", Enabled: true}},
|
|
})
|
|
store.stages[live.ID] = append(store.stages[live.ID], memStageRecord{
|
|
id: live.ID + 100, stageKey: "V0.5_feasibility", position: 1, enabled: true, status: StagePending,
|
|
tasks: []memDocTaskRecord{{id: 900, taskKey: "bd_intake", enabled: true, status: "pending"}},
|
|
})
|
|
store.mu.Unlock()
|
|
|
|
_, err := svc.ResolveMessageScopeForTask(ctx, 43, "intake")
|
|
if err == nil || !errors.Is(err, ErrTaskScopeNotResolved) {
|
|
t.Fatalf("same task in two stages must stay unresolved, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskKeyToStage_LegacyAlias(t *testing.T) {
|
|
plan := validPlan()
|
|
plan.Stages[0].DocTasks[0].TaskKey = "bd_user_requirements"
|
|
if got := TaskKeyToStage(plan, "user_requirements"); got != "V0.1_triage" {
|
|
t.Fatalf("TaskKeyToStage=%q", got)
|
|
}
|
|
if got := TaskKeyToStage(plan, "intake"); got != "" {
|
|
t.Fatalf("unrelated legacy key must miss, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestScopeFromMetadata_RoundTrip(t *testing.T) {
|
|
meta := map[string]any{
|
|
"scope_kind": ScopeWorkflowStage,
|
|
"workflow_instance_id": float64(89),
|
|
"stage_instance_id": float64(201),
|
|
"stage_key": "V0.1_triage",
|
|
}
|
|
scope := ScopeFromMetadata(meta)
|
|
if scope.ScopeKind != ScopeWorkflowStage || scope.WorkflowInstanceID != 89 || scope.StageInstanceID != 201 || scope.StageKey != "V0.1_triage" {
|
|
t.Fatalf("unexpected scope: %+v", scope)
|
|
}
|
|
}
|