package server import ( "context" "errors" "strings" "sync/atomic" "testing" "time" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/storage" "chat-agent-system/internal/stream" "chat-agent-system/internal/workflowscope" ) func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testing.T) { docs := []docgov.WorkflowDocumentSummary{ {DocID: "doc_proj143_intake", TaskKey: "intake", Title: "用户初始需求收集表"}, {DocID: "doc_proj143_research", TaskKey: "research", Title: "技术与市场调研报告"}, } doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表", "", nil) if err != nil { t.Fatalf("select: %v", err) } if doc.DocID != "doc_proj143_intake" { t.Fatalf("doc=%s", doc.DocID) } } func TestSelectReadableWorkflowDocumentDoesNotGuessWhenTargetMissing(t *testing.T) { docs := []docgov.WorkflowDocumentSummary{ {DocID: "doc_a", TaskKey: "intake", Title: "用户初始需求收集表"}, {DocID: "doc_b", TaskKey: "research", Title: "调研报告"}, } if _, err := selectReadableWorkflowDocument(docs, "", "", nil); err == nil { t.Fatal("expected target-required error") } } func TestSelectReadableWorkflowDocumentScopesToActiveWorkflow(t *testing.T) { docs := []docgov.WorkflowDocumentSummary{ {DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"}, {DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"}, } scope := map[string]bool{"bd_research": true, "research": true} doc, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope) if err != nil { t.Fatalf("in-scope select: %v", err) } if doc.TaskKey != "bd_research" { t.Fatalf("want bd_research, got %s", doc.TaskKey) } _, err = selectReadableWorkflowDocument(docs, "项目建议书", "main", scope) if err == nil { t.Fatal("expected wrong-workflow error") } var wrong *coordinator.ErrWrongWorkflow if !errors.As(err, &wrong) { t.Fatalf("want ErrWrongWorkflow, got %T %v", err, err) } if !strings.Contains(wrong.Message, "切换") { t.Fatalf("guidance should mention 切换, got %q", wrong.Message) } } func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) { docs := []docgov.WorkflowDocumentSummary{ {DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"}, } doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "", nil) if err != nil { t.Fatalf("legacy passthrough: %v", err) } if doc.TaskKey != "rd_proposal" { t.Fatalf("want rd_proposal, got %s", doc.TaskKey) } } func TestSelectReadableWeightedAllowsExactCrossRead(t *testing.T) { workflowscope.SetModeForTest(workflowscope.ModeWeighted) t.Cleanup(workflowscope.ResetModeForTest) docs := []docgov.WorkflowDocumentSummary{ {DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"}, {DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"}, } scope := map[string]bool{"bd_research": true} doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "main", scope) if err != nil { t.Fatalf("weighted cross read: %v", err) } if doc.TaskKey != "rd_proposal" { t.Fatalf("want rd_proposal, got %s", doc.TaskKey) } inScope, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope) if err != nil || inScope.TaskKey != "bd_research" { t.Fatalf("in-scope still wins: %v %v", inScope, err) } } func TestCrossWorkflowTaskVisible(t *testing.T) { open := &projectinstance.StageAccess{ Restricted: true, ViewableTaskKeys: map[string]bool{"bd_research": true}, } scope := map[string]bool{"bd_research": true, "bd_hidden": true} if !crossWorkflowTaskVisible(open, scope, "rd_proposal") { t.Fatal("foreign key should be readable when the caller can view the active instance") } if crossWorkflowTaskVisible(open, scope, "bd_hidden") { t.Fatal("in-scope but not viewable must be denied") } closed := &projectinstance.StageAccess{Restricted: true, ViewableTaskKeys: map[string]bool{}} if crossWorkflowTaskVisible(closed, scope, "rd_proposal") { t.Fatal("closed instance must deny cross read") } if !crossWorkflowTaskVisible(nil, scope, "rd_proposal") { t.Fatal("unrestricted access must allow") } } func TestPickLatestFileReference(t *testing.T) { older := &storage.FileReference{ID: 1, FileName: "a.txt", CreatedAt: time.Unix(100, 0)} newer := &storage.FileReference{ID: 2, FileName: "b.txt", CreatedAt: time.Unix(200, 0)} got := pickLatestFileReference([]*storage.FileReference{older, newer, nil}) if got == nil || got.ID != 2 { t.Fatalf("want id=2, got %+v", got) } sameTimeHigherID := &storage.FileReference{ID: 9, FileName: "c.txt", CreatedAt: time.Unix(200, 0)} got = pickLatestFileReference([]*storage.FileReference{newer, sameTimeHigherID}) if got == nil || got.ID != 9 { t.Fatalf("same created_at should prefer higher id, got %+v", got) } } func TestResolveLatestUploadDocument_TextAsset(t *testing.T) { cfg := &config.Config{Env: "test", DataDir: t.TempDir()} svc := bizdev.MustNewService(t, cfg) srv := NewServer(cfg, svc) if srv.cas() == nil { t.Fatal("expected CAS") } projectID := int64(42) userID := int64(7) _, err := srv.cas().StoreFile(context.Background(), projectID, userID, "shockwave.txt", "material", []byte("冲击波治疗仪核心适应症:软组织损伤与疼痛管理。\n关键约束:治疗强度需医师确认。"), "text/plain") if err != nil { t.Fatalf("store: %v", err) } doc, err := srv.resolveLatestUploadDocument(context.Background(), projectID) if err != nil { t.Fatalf("resolve: %v", err) } if doc == nil || !strings.Contains(doc.Markdown, "冲击波治疗仪") { t.Fatalf("unexpected doc: %+v", doc) } if doc.Title != "shockwave" { t.Fatalf("title=%q", doc.Title) } if doc.LifecycleState != "uploaded" { t.Fatalf("lifecycle=%q", doc.LifecycleState) } } func TestResolveLatestUploadDocument_NoAsset(t *testing.T) { cfg := &config.Config{Env: "test", DataDir: t.TempDir()} svc := bizdev.MustNewService(t, cfg) srv := NewServer(cfg, svc) _, err := srv.resolveLatestUploadDocument(context.Background(), 99) if err == nil { t.Fatal("expected ErrNoUploadAsset") } var noAsset *coordinator.ErrNoUploadAsset if !errors.As(err, &noAsset) { t.Fatalf("want ErrNoUploadAsset, got %T %v", err, err) } } func TestWaitForUploadParse_PendingThenDone(t *testing.T) { prev := uploadParsePollInterval uploadParsePollInterval = 15 * time.Millisecond t.Cleanup(func() { uploadParsePollInterval = prev }) var calls atomic.Int32 stateFn := func() string { n := calls.Add(1) if n >= 3 { return "done" } return "pending" } var phases []string ctx, st := stream.WithListener(context.Background(), false, func(ev stream.StreamEvent) { if ev.Type == stream.EventPhase && ev.Phase != nil { phases = append(phases, ev.Phase.PhaseID+":"+ev.Phase.Message) } }) _ = st got := waitForUploadParse(ctx, stateFn, "冲击波说明书.pdf", 2*time.Second) if got != "done" { t.Fatalf("got %q want done", got) } if calls.Load() < 3 { t.Fatalf("expected multiple polls, calls=%d", calls.Load()) } if len(phases) == 0 { t.Fatal("expected document_parsing phase events") } if !strings.Contains(phases[0], "document_parsing:") { t.Fatalf("first phase=%q", phases[0]) } if !strings.Contains(phases[0], "MinerU") && !strings.Contains(phases[0], "解析") { t.Fatalf("initial phase should mention parse wait: %q", phases[0]) } } func TestWaitForUploadParse_TimeoutSlow(t *testing.T) { prev := uploadParsePollInterval uploadParsePollInterval = 10 * time.Millisecond t.Cleanup(func() { uploadParsePollInterval = prev }) got := waitForUploadParse(context.Background(), func() string { return "pending" }, "spec.pdf", 40*time.Millisecond) if got != "slow" { t.Fatalf("got %q want slow", got) } } func TestWaitForUploadParse_ContextCancel(t *testing.T) { prev := uploadParsePollInterval uploadParsePollInterval = 50 * time.Millisecond t.Cleanup(func() { uploadParsePollInterval = prev }) ctx, cancel := context.WithCancel(context.Background()) cancel() got := waitForUploadParse(ctx, func() string { return "running" }, "spec.pdf", time.Second) if got != "running" && got != "" { // cancelled before/at select: returns last observed state t.Fatalf("got %q on cancel", got) } } func TestWaitForUploadParse_Failed(t *testing.T) { prev := uploadParsePollInterval uploadParsePollInterval = 10 * time.Millisecond t.Cleanup(func() { uploadParsePollInterval = prev }) got := waitForUploadParse(context.Background(), func() string { return "failed" }, "spec.pdf", time.Second) if got != "failed" { t.Fatalf("got %q want failed", got) } }