package workbench import ( "context" "errors" "strings" "testing" "time" "github.com/Tencent/WeKnora/internal/types" "github.com/google/uuid" ) func TestDetachPreservesNativeAndImportedHistory(t *testing.T) { for _, kind := range []string{"training-maps", "flows"} { for _, imported := range []bool{false, true} { t.Run(kind+map[bool]string{false: "/native", true: "/imported"}[imported], func(t *testing.T) { s := sqliteWorkbench(t) ctx := workbenchContext(1, "alice", types.TenantRoleContributor) parent := seedResource(t, s, 1, "alice", kind) task := seedResource(t, s, 1, "alice", "content-tasks") original := TaskData{MapID: "map", FlowID: "flow", Review: "historical review", Composed: "historical final", Segments: []Segment{{ID: "s1", Status: "accepted", Content: "historical paragraph", Review: "historical segment review", SourceIDs: []string{"source"}}}} if kind == "training-maps" { original.MapID = parent.ID } else { original.FlowID = parent.ID } task.Data = asJSON(original) if imported { task.Data["legacy_import"] = JSON{"read_only": true, "requires_review": true, "source_id": "old-task", "missing_configuration": []string{"day"}} task.Data["legacy_history"] = JSON{"qcreports": []JSON{{"result": "historical report", "passed": false}}} } metadata, history := compact(task.Data["legacy_import"]), compact(task.Data["legacy_history"]) if err := s.db.Model(task).Update("data", task.Data).Error; err != nil { t.Fatal(err) } if w := deleteWorkbenchResource(s, ctx, parent, true); w.Code != 200 { t.Fatalf("detach failed: status %d", w.Code) } stored, err := s.get(ctx, task.Kind, task.ID) if err != nil { t.Fatal(err) } data, err := decode[TaskData](stored.Data) if err != nil { t.Fatal(err) } if stored.Status != "needs_configuration" || data.Review != original.Review || data.Composed != original.Composed || compact(data.Segments) != compact(original.Segments) || compact(stored.Data["legacy_import"]) != metadata || compact(stored.Data["legacy_history"]) != history { t.Fatal("detachment changed historical output or import metadata") } if (kind == "training-maps" && data.MapID != "") || (kind == "flows" && data.FlowID != "") { t.Fatal("deleted reference was retained") } for _, action := range []string{"decompose", "generate", "revise", "accept", "skip", "review", "compose"} { job, err := s.submit(ctx, task.Kind, task.ID, action, ActionRequest{Version: stored.Version, Advice: "advice", SegmentID: "s1"}) if !imported && taskActionUsesOnlySavedContent(action) { if err != nil { t.Fatalf("saved prose action %s depends on detached sources: %v", action, err) } if err := s.db.Delete(job).Error; err != nil { t.Fatal(err) } continue } if !errors.Is(err, ErrInvalid) { t.Fatalf("detached task accepted action %s: %v", action, err) } } var jobs int64 if err := s.db.Model(&Job{}).Count(&jobs).Error; err != nil || jobs != 0 { t.Fatal("rejected actions were queued") } }) } } } func TestImportGuardsRejectExecutionBeforeAIAndAllowRename(t *testing.T) { for _, metadata := range []any{JSON{"read_only": true}, JSON{"requires_review": true}, JSON{"needs_configuration": true}, JSON{"missing_configuration": []string{"agent_id"}}, JSON{"read_only": "false"}, nil, "invalid"} { for _, kind := range []string{"training-maps", "content-tasks", "flows"} { r := &Resource{Kind: kind, Status: "draft", Data: JSON{"legacy_import": metadata, "legacy_history": JSON{"preserved": true}}} original := compact(r.Data) s := NewService(nil) var err error if kind == "content-tasks" { err = s.executeTask(context.Background(), &Job{Action: "decompose"}, r, ActionRequest{}) } else { err = s.executeMap(context.Background(), &Job{Action: "generate"}, r, ActionRequest{}) } if !errors.Is(err, ErrInvalid) { t.Fatalf("invalid imported state executed: %v", err) } if err = applyManualUpdate(r, resourceRequest{Name: "renamed", Data: r.Data}); err != nil { t.Fatalf("owner cannot rename historical record: %v", err) } if compact(r.Data) != original { t.Fatal("rejected action changed historical data") } } } } func TestCompatibleImportMetadataSurvivesActionsAndCannotBeRewritten(t *testing.T) { metadata := JSON{"source_id": "old-map", "read_only": false, "requires_review": false, "needs_configuration": false} history := JSON{"source_status": "reviewed", "source_text": "historical framework"} r := &Resource{Kind: "training-maps", Name: "map", Data: asJSON(MapData{Topic: "topic", AgentID: "author", DaysCount: 1, Days: []Day{{Day: 1, Title: "day", Framework: "framework"}}})} r.Data["legacy_import"], r.Data["legacy_history"] = metadata, history s := NewService(nil) if err := s.executeMap(context.Background(), &Job{Action: "confirm-day"}, r, ActionRequest{Day: 1}); err != nil { t.Fatal(err) } data, _ := decode[MapData](r.Data) if !data.Days[0].Confirmed || compact(r.Data["legacy_import"]) != compact(metadata) || compact(r.Data["legacy_history"]) != compact(history) { t.Fatal("mapped resource action lost historical metadata") } if err := applyManualUpdate(r, resourceRequest{Name: "renamed", Data: asJSON(data)}); err != nil { t.Fatal(err) } if compact(r.Data["legacy_import"]) != compact(metadata) || compact(r.Data["legacy_history"]) != compact(history) { t.Fatal("configuration update lost migration metadata") } for _, key := range legacyMetadataKeys { proposed := resourceData(r, data) proposed[key] = JSON{"changed": true} if err := applyManualUpdate(r, resourceRequest{Name: "renamed", Data: proposed}); !errors.Is(err, ErrInvalid) { t.Fatalf("reserved field %s can be rewritten: %v", key, err) } } if err := validateLegacyMetadataUpdate(nil, JSON{"legacy_import": metadata}); !errors.Is(err, ErrInvalid) { t.Fatal("client can create forged import metadata") } } func TestWorkerRejectsImportedTaskAlreadyInQueue(t *testing.T) { s := sqliteWorkbench(t) if err := s.db.AutoMigrate(&types.TenantMember{}, &types.Tenant{}); err != nil { t.Fatal(err) } if err := s.db.Create(&types.Tenant{ID: 1, Status: "active"}).Error; err != nil { t.Fatal(err) } member := types.TenantMember{TenantID: 1, UserID: "alice", Role: types.TenantRoleContributor, Status: types.TenantMemberStatusActive} if err := s.db.Create(&member).Error; err != nil { t.Fatal(err) } r := seedResource(t, s, 1, "alice", "content-tasks") r.Data = asJSON(TaskData{Review: "historical review", Composed: "historical final", Segments: []Segment{{ID: "s1", Content: "historical paragraph"}}}) r.Data["legacy_import"] = JSON{"read_only": true, "requires_review": true, "needs_configuration": true} if err := s.db.Model(r).Update("data", r.Data).Error; err != nil { t.Fatal(err) } before := compact(r.Data) until := time.Now().Add(time.Minute) j := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Action: "decompose", Status: "running", LeaseToken: "test-lease", LeaseUntil: &until, Request: asJSON(ActionRequest{Version: 1})} if err := s.db.Create(&j).Error; err != nil { t.Fatal(err) } s.run(context.Background(), &j) var job Job if err := s.db.First(&job, "id = ?", j.ID).Error; err != nil { t.Fatal(err) } if job.Status != "failed" || !strings.Contains(job.Error, "只读历史资料") || len(job.Generation) != 0 { t.Fatal("queued legacy job was not rejected before model execution") } stored, err := s.get(workbenchContext(1, "alice", types.TenantRoleContributor), r.Kind, r.ID) if err != nil || compact(stored.Data) != before || stored.Version != 1 { t.Fatal("legacy worker rejection changed history") } } func TestTaskMissingConfigurationFailsBeforeQueueing(t *testing.T) { s := sqliteWorkbench(t) r := seedResource(t, s, 1, "alice", "content-tasks") ctx := workbenchContext(1, "alice", types.TenantRoleContributor) if _, err := s.submit(ctx, r.Kind, r.ID, "decompose", ActionRequest{Version: 1}); !errors.Is(err, ErrInvalid) || !strings.Contains(err.Error(), "任务缺少") { t.Fatalf("missing configuration was not rejected: %v", err) } } func TestReadOnlyImportCanBeDeletedWithOriginalArchived(t *testing.T) { s := sqliteWorkbench(t) ctx := workbenchContext(1, "alice", types.TenantRoleContributor) r := seedResource(t, s, 1, "alice", "content-tasks") r.Data["legacy_import"] = JSON{"read_only": true} if err := s.db.Model(r).Update("data", r.Data).Error; err != nil { t.Fatal(err) } if response := deleteWorkbenchResource(s, ctx, r, true); response.Code != 200 { t.Fatalf("owner cannot delete imported resource: status %d", response.Code) } if _, err := s.get(ctx, r.Kind, r.ID); err == nil { t.Fatal("deleted resource remains visible") } var revision Revision if err := s.db.First(&revision, "resource_id = ?", r.ID).Error; err != nil || compact(revision.Data) != compact(r.Data) { t.Fatal("deleted import original was not archived") } } func TestTaskCannotRunUsingProtectedImportedParents(t *testing.T) { for _, parentKind := range []string{"training-maps", "flows"} { t.Run(parentKind, func(t *testing.T) { s := sqliteWorkbench(t) s.agents = generationAgentStub{} ctx := workbenchContext(1, "alice", types.TenantRoleContributor) m := seedResource(t, s, 1, "alice", "training-maps") m.Status = "confirmed" m.Data = asJSON(MapData{Days: []Day{{Day: 1, Title: "day"}}}) flow := seedResource(t, s, 1, "alice", "flows") flow.Data = asJSON(FlowData{Nodes: []FlowNode{{ID: "n1", AgentID: "author"}}}) parent := m if parentKind == "flows" { parent = flow } parent.Data["legacy_import"] = JSON{"read_only": true, "requires_review": true} for _, resource := range []*Resource{m, flow} { if err := s.db.Save(resource).Error; err != nil { t.Fatal(err) } } task := seedResource(t, s, 1, "alice", "content-tasks") task.Data = asJSON(TaskData{MapID: m.ID, FlowID: flow.ID, Day: 1, AgentID: "author"}) if err := s.db.Model(task).Update("data", task.Data).Error; err != nil { t.Fatal(err) } if _, err := s.submit(ctx, task.Kind, task.ID, "decompose", ActionRequest{Version: 1}); !errors.Is(err, ErrInvalid) || !strings.Contains(err.Error(), "只读历史资料") { t.Fatalf("protected parent entered executable task: %v", err) } }) } }