- Server no longer mirrors app.Runtime services (gov/svc/pwSvc/...): 574 call sites now use accessors in runtime_accessors.go - backgroundLoop (mutex+sync.Once) stops sweeper/publisher/reminder/wsGC goroutines race-free (baseline44161d2areproduced DATA RACE; now green under -race) - goose 00071 duplicate (dialogue merge1da17a28brought two 00071): context-conflict-adjudications renumbered to 00072; dev DB applied only through 00070 so zero data migration - task_finalize tests build minimal app.Runtime; test injection via serverTestOverrides + setOverride* (explicit-nil markers for fail-closed cases)
263 lines
8.7 KiB
Go
263 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|