Merge pull request 'issue#2 收官:按包测试库 + 生产同构审批夹具;短测全量必需门禁' (#18) from feature/issue2-pg-tests-perpackage-db-2026-09-29 into dev
CI / go-vet (push) Successful in 40s
CI / backup-restore-drill (push) Successful in 30s
CI / web (push) Successful in 33s

This commit was merged in pull request #18.
This commit is contained in:
2026-09-29 10:11:42 +00:00
45 changed files with 335 additions and 153 deletions
+2 -6
View File
@@ -42,14 +42,10 @@ jobs:
- name: arch-check (block cleared kinds) - name: arch-check (block cleared kinds)
run: ./scripts/arch-check.sh --mode block run: ./scripts/arch-check.sh --mode block
- name: go test (short, required since 2026-09-29) - name: go test (short, required)
# skip 清单 = issue #2 存量 PG 集成失败(2026-09-29 已修 10/15,余 5 点名跟踪);
# 新增失败不会被跳过,直接阻断。
env: env:
TEST_DATABASE_URL: postgres://app:ci_secret_db_pass@postgres:5432/agent_system_ci?sslmode=disable TEST_DATABASE_URL: postgres://app:ci_secret_db_pass@postgres:5432/agent_system_ci?sslmode=disable
run: > run: go test -short -timeout 20m ./...
go test -short -timeout 20m ./...
-skip 'TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService|TestPG_ActivationAndStageAdvance|TestE2E_WorkflowGovernance_CompleteAndReplan|TestPhase5_ConcurrentStageAdvance_PG|TestWorkflowGovernance_PG_ActivateApproveAdvanceChain'
- name: secrets scan (tracked files) - name: secrets scan (tracked files)
run: | run: |
Executable
BIN
View File
Binary file not shown.
+2 -6
View File
@@ -1,9 +1,9 @@
package auth_test package auth_test
import ( import (
"chat-agent-system/internal/db"
"context" "context"
"errors" "errors"
"os"
"testing" "testing"
"time" "time"
@@ -15,11 +15,7 @@ import (
func openTestPool(t *testing.T) *pgxpool.Pool { func openTestPool(t *testing.T) *pgxpool.Pool {
t.Helper() t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL") dsn := db.SkipIfNoTestDatabaseURL(t)
if dsn == "" {
t.Skip("TEST_DATABASE_URL must be set for PostgreSQL integration tests (refusing default dev database)")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
pool, err := pgxpool.New(ctx, dsn) pool, err := pgxpool.New(ctx, dsn)
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/config" "chat-agent-system/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
) )
// Stores 认证账户存储与会话存储组合。 // Stores 认证账户存储与会话存储组合。
+9 -9
View File
@@ -66,15 +66,15 @@ func (m *ContextManager) IndexApprovedDocument(ctx context.Context, doc *docgov.
regs := extractRegulations(sec.Content) regs := extractRegulations(sec.Content)
meta := map[string]any{ meta := map[string]any{
"task_key": doc.TaskKey, "task_key": doc.TaskKey,
"artifact_title": doc.Title, "artifact_title": doc.Title,
"section_title": sec.Title, "section_title": sec.Title,
"regulations": regs, "regulations": regs,
"author_name": doc.AuthorName, "author_name": doc.AuthorName,
"reviewer_name": doc.ReviewerName, "reviewer_name": doc.ReviewerName,
"approver_name": doc.ApproverName, "approver_name": doc.ApproverName,
"stage": doc.Stage, "stage": doc.Stage,
"approved_at": doc.ApprovedAt, "approved_at": doc.ApprovedAt,
} }
if m.pool != nil { if m.pool != nil {
+4 -4
View File
@@ -17,10 +17,10 @@ func TestSweepExpiredProcessingTurns_Memory(t *testing.T) {
future := time.Now().Add(5 * time.Minute) future := time.Now().Add(5 * time.Minute)
mgr.mu.Lock() mgr.mu.Lock()
mgr.turns[turnMemKey("sess-sweep", "turn-expired")] = &ChatTurnRecord{ mgr.turns[turnMemKey("sess-sweep", "turn-expired")] = &ChatTurnRecord{
SessionID: "sess-sweep", SessionID: "sess-sweep",
ClientTurnID: "turn-expired", ClientTurnID: "turn-expired",
Status: TurnStatusProcessing, Status: TurnStatusProcessing,
LeaseExpiresAt: &past, LeaseExpiresAt: &past,
} }
mgr.turns[turnMemKey("sess-sweep", "turn-fresh")] = &ChatTurnRecord{ mgr.turns[turnMemKey("sess-sweep", "turn-fresh")] = &ChatTurnRecord{
SessionID: "sess-sweep", SessionID: "sess-sweep",
+2 -2
View File
@@ -12,8 +12,8 @@ import (
type ChatReadPosition struct { type ChatReadPosition struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
LastReadMessageID int64 `json:"last_read_message_id"` // 连续已读基线 LastReadMessageID int64 `json:"last_read_message_id"` // 连续已读基线
DismissedUnreadIDs []int64 `json:"dismissed_unread_ids"` // 离散跳读/倒序消费的消息 ID DismissedUnreadIDs []int64 `json:"dismissed_unread_ids"` // 离散跳读/倒序消费的消息 ID
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
-1
View File
@@ -787,4 +787,3 @@ func enrichMessageExportCapability(r *ChatMessageRecord) {
} }
r.ExportCapability = evalCap r.ExportCapability = evalCap
} }
+9 -8
View File
@@ -1,6 +1,7 @@
package ctxmgr_test package ctxmgr_test
import ( import (
"chat-agent-system/internal/db"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -241,11 +242,7 @@ func TestChatWindow_MemoryEquivalenceAndPagination(t *testing.T) {
func TestChatWindow_DatabaseIntegration(t *testing.T) { func TestChatWindow_DatabaseIntegration(t *testing.T) {
dsn := os.Getenv("CONTEXT_DB_DSN") dsn := os.Getenv("CONTEXT_DB_DSN")
if dsn == "" { if dsn == "" {
dsn = os.Getenv("TEST_DATABASE_URL") dsn = db.SkipIfNoTestDatabaseURL(t)
}
if dsn == "" {
t.Skip("CONTEXT_DB_DSN or TEST_DATABASE_URL must be set (refusing default dev database)")
return
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -262,6 +259,10 @@ func TestChatWindow_DatabaseIntegration(t *testing.T) {
return return
} }
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migration failed: %v", err)
}
mgr := ctxmgr.NewContextManager(pool, nil, ctxmgr.DefaultTimeDecayConfig()) mgr := ctxmgr.NewContextManager(pool, nil, ctxmgr.DefaultTimeDecayConfig())
sessID := fmt.Sprintf("sess_db_window_%d", time.Now().UnixNano()) sessID := fmt.Sprintf("sess_db_window_%d", time.Now().UnixNano())
@@ -348,9 +349,9 @@ func TestListChatCategories_MemoryMode(t *testing.T) {
sessID := "sess_mem_cat_test" sessID := "sess_mem_cat_test"
// 注入不同分类的消息 // 注入不同分类的消息
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "阶段推进", "ADVANCE_STAGE", "", nil) // task _, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "阶段推进", "ADVANCE_STAGE", "", nil) // task
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "启动任务", "TRIGGER_TASK", "", nil) // task _, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "启动任务", "TRIGGER_TASK", "", nil) // task
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "合规咨询", "QUERY_REGULATIONS", "", nil) // consult _, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "合规咨询", "QUERY_REGULATIONS", "", nil) // consult
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "立项里程碑", "", "", map[string]any{"milestone": true}) // milestone _, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "立项里程碑", "", "", map[string]any{"milestone": true}) // milestone
stats, err := mgr.ListChatCategories(ctx, sessID) stats, err := mgr.ListChatCategories(ctx, sessID)
+12 -12
View File
@@ -1,11 +1,11 @@
package ctxmgr package ctxmgr
import ( import (
"log/slog"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"sync" "sync"
"time" "time"
@@ -23,17 +23,17 @@ type ContextManager struct {
embedder VectorEmbedder embedder VectorEmbedder
// 离线降级内存缓存 // 离线降级内存缓存
mu sync.RWMutex mu sync.RWMutex
users map[int64]*UserPreference users map[int64]*UserPreference
projects map[int64]*ProjectCollectiveContext projects map[int64]*ProjectCollectiveContext
messages map[string][]ChatMessageRecord // session_id -> messages messages map[string][]ChatMessageRecord // session_id -> messages
sessions map[string]*ChatSessionSummary // session_id -> summary sessions map[string]*ChatSessionSummary // session_id -> summary
activities map[int64][]ActivityItem // project_id -> activities activities map[int64][]ActivityItem // project_id -> activities
memoryChunks []MemoryChunkItem // 语义记忆切片缓存 memoryChunks []MemoryChunkItem // 语义记忆切片缓存
summaries map[string]*ChatSummary // session_id -> rolling summary summaries map[string]*ChatSummary // session_id -> rolling summary
digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest
readPositions map[string]*ChatReadPosition // session_id:user_id -> read position readPositions map[string]*ChatReadPosition // session_id:user_id -> read position
turns map[string]*ChatTurnRecord // sessionID|clientTurnID -> 幂等回合(仅内存降级) turns map[string]*ChatTurnRecord // sessionID|clientTurnID -> 幂等回合(仅内存降级)
cache *MultiLevelCache // 多级统一缓存系统 (C1-C5) cache *MultiLevelCache // 多级统一缓存系统 (C1-C5)
semanticSummarizer SemanticSummarizer // no-tool summarizer; nil → heuristic default semanticSummarizer SemanticSummarizer // no-tool summarizer; nil → heuristic default
shadowJobs []SemanticShadowJob // in-process durable fallback queue shadowJobs []SemanticShadowJob // in-process durable fallback queue
+6 -5
View File
@@ -1,6 +1,7 @@
package ctxmgr package ctxmgr
import ( import (
"chat-agent-system/internal/db"
"context" "context"
"os" "os"
"strings" "strings"
@@ -130,11 +131,7 @@ func TestContextManager_TimeDecayActivities(t *testing.T) {
func TestContextManager_DatabaseIntegration(t *testing.T) { func TestContextManager_DatabaseIntegration(t *testing.T) {
dsn := os.Getenv("CONTEXT_DB_DSN") dsn := os.Getenv("CONTEXT_DB_DSN")
if dsn == "" { if dsn == "" {
dsn = os.Getenv("TEST_DATABASE_URL") dsn = db.SkipIfNoTestDatabaseURL(t)
}
if dsn == "" {
t.Skip("CONTEXT_DB_DSN or TEST_DATABASE_URL must be set (refusing default dev database)")
return
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
@@ -152,6 +149,10 @@ func TestContextManager_DatabaseIntegration(t *testing.T) {
return return
} }
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migration failed: %v", err)
}
mgr := NewContextManager(pool, nil, DefaultTimeDecayConfig()) mgr := NewContextManager(pool, nil, DefaultTimeDecayConfig())
// 准备真实项目与用户 // 准备真实项目与用户
+5 -5
View File
@@ -13,11 +13,11 @@ const defaultProjectDialogueLimit = 120
// ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。 // ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。
type ProjectDialogueCollectResult struct { type ProjectDialogueCollectResult struct {
Lines []string Lines []string
ExcludedPrivate int ExcludedPrivate int
ExcludedNoAccess int ExcludedNoAccess int
AllowedStageKey string AllowedStageKey string
StageFilteredCount int StageFilteredCount int
} }
// CollectProjectDialogueOptions 采集选项。 // CollectProjectDialogueOptions 采集选项。
+3 -3
View File
@@ -16,9 +16,9 @@ func TestCollectProjectDialogue_ExcludesPrivateAndIncludesShared(t *testing.T) {
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "[private] 不应纳入", "", "", meta) _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "[private] 不应纳入", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "项目需要支持多用户协作", "", "", meta) _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "项目需要支持多用户协作", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "assistant", "好的,我会整理需求要点。", "", "", map[string]any{ _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "assistant", "好的,我会整理需求要点。", "", "", map[string]any{
"project_id": projectID, "project_id": projectID,
"export_policy": string(replyexport.PolicyFull), "export_policy": string(replyexport.PolicyFull),
"content_kind": string(replyexport.KindDocumentText), "content_kind": string(replyexport.KindDocumentText),
}) })
res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{}) res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{})
+9 -9
View File
@@ -62,15 +62,15 @@ type SemanticSummaryResult struct {
// gray rollout a replaced report keeps the displaced deterministic narrative // gray rollout a replaced report keeps the displaced deterministic narrative
// so later deterministic regeneration merges clean material (rollback-safe). // so later deterministic regeneration merges clean material (rollback-safe).
type SemanticShadowReport struct { type SemanticShadowReport struct {
Status string `json:"status"` Status string `json:"status"`
Narrative string `json:"narrative,omitempty"` Narrative string `json:"narrative,omitempty"`
DisplacedNarrative string `json:"displaced_narrative,omitempty"` DisplacedNarrative string `json:"displaced_narrative,omitempty"`
InputDigest string `json:"input_digest,omitempty"` InputDigest string `json:"input_digest,omitempty"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Protocol string `json:"protocol,omitempty"` Protocol string `json:"protocol,omitempty"`
ToolPermissions string `json:"tool_permissions"` ToolPermissions string `json:"tool_permissions"`
FreshnessNote string `json:"freshness_note,omitempty"` FreshnessNote string `json:"freshness_note,omitempty"`
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
} }
// SemanticSummarizer produces a narrative from minimized messages. // SemanticSummarizer produces a narrative from minimized messages.
+1 -1
View File
@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"unicode/utf8"
"time" "time"
"unicode/utf8"
) )
const ( const (
+2 -2
View File
@@ -26,8 +26,8 @@ const (
// SummaryFeatureFlags controls semantic summary rollout. REPLACE swaps the // SummaryFeatureFlags controls semantic summary rollout. REPLACE swaps the
// served chat Narrative only for allowlisted sessions (fail-closed gray). // served chat Narrative only for allowlisted sessions (fail-closed gray).
type SummaryFeatureFlags struct { type SummaryFeatureFlags struct {
Shadow bool Shadow bool
Replace bool Replace bool
// ReplaceAllSessions is the "*" wildcard for ReplaceSessions: every // ReplaceAllSessions is the "*" wildcard for ReplaceSessions: every
// session (present and future) is eligible while the master Replace flag // session (present and future) is eligible while the master Replace flag
// stays on. An empty allowlist still replaces nothing. // stays on. An empty allowlist still replaces nothing.
+2 -8
View File
@@ -23,10 +23,7 @@ func TestMigrationGate_ExpectedFilesMatchAppliedVersion(t *testing.T) {
t.Fatal("expected at least one migration file") t.Fatal("expected at least one migration file")
} }
dsn := os.Getenv("TEST_DATABASE_URL") dsn := SkipIfNoTestDatabaseURL(t)
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; migration gate file count only")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -280,10 +277,7 @@ func rewriteDatabaseName(dsn, name string) string {
} }
func TestRunMigrationsEmbeddedFallback(t *testing.T) { func TestRunMigrationsEmbeddedFallback(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL") dsn := SkipIfNoTestDatabaseURL(t)
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; embedded fallback needs PG")
}
ctx := context.Background() ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn) pool, err := pgxpool.New(ctx, dsn)
if err != nil { if err != nil {
+101 -1
View File
@@ -2,7 +2,14 @@ package db
import ( import (
"context" "context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net/url"
"os" "os"
"path/filepath"
"runtime"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -31,25 +38,118 @@ func RunMigrationsOnceForTest(ctx context.Context, pool *pgxpool.Pool, dsn strin
const testDatabaseEnv = "TEST_DATABASE_URL" const testDatabaseEnv = "TEST_DATABASE_URL"
// RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。 // RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。
// 返回按调用方包派生的独立数据库(<base>_<pkgHash>):包内共享、包间隔离,
// 消除 go test 并行包对同一库的迁移/种子互相干扰(2026-09-29 issue #2)。
func RequireTestDatabaseURL(t *testing.T) string { func RequireTestDatabaseURL(t *testing.T) string {
t.Helper() t.Helper()
dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv))
if dsn == "" { if dsn == "" {
t.Fatalf("%s must be set for PostgreSQL integration tests (refusing default dev database)", testDatabaseEnv) t.Fatalf("%s must be set for PostgreSQL integration tests (refusing default dev database)", testDatabaseEnv)
} }
return dsn return derivedPackageDSN(t, dsn)
} }
// SkipIfNoTestDatabaseURL 无隔离测试库时跳过(单元测试包内可选 PG 用例)。 // SkipIfNoTestDatabaseURL 无隔离测试库时跳过(单元测试包内可选 PG 用例)。
// 同样返回按包派生库。
func SkipIfNoTestDatabaseURL(t *testing.T) string { func SkipIfNoTestDatabaseURL(t *testing.T) string {
t.Helper() t.Helper()
dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv))
if dsn == "" { if dsn == "" {
t.Skipf("%s not set; skipping PostgreSQL integration test", testDatabaseEnv) t.Skipf("%s not set; skipping PostgreSQL integration test", testDatabaseEnv)
} }
return derivedPackageDSN(t, dsn)
}
var (
pkgDBMu sync.Mutex
pkgDBReady = map[string]string{} // 派生 DSN 缓存(进程内)
)
func derivedPackageDSN(t *testing.T, baseDSN string) string {
t.Helper()
_, file, _, ok := runtime.Caller(2) // 跳过 helper 自身,取调用测试文件
if !ok {
return baseDSN
}
pkgDir := filepath.ToSlash(filepath.Dir(file))
// 仅取模块内相对路径段,避免绝对路径差异影响哈希
if i := strings.Index(pkgDir, "internal/"); i >= 0 {
pkgDir = pkgDir[i:]
} else if i := strings.Index(pkgDir, "cmd/"); i >= 0 {
pkgDir = pkgDir[i:]
}
sum := sha256.Sum256([]byte(pkgDir))
suffix := hex.EncodeToString(sum[:4])
u, err := url.Parse(baseDSN)
if err != nil || u.Path == "" {
return baseDSN // 非标准 DSN:退回原值,行为同旧版
}
baseDB := strings.TrimPrefix(u.Path, "/")
if baseDB == "" {
return baseDSN
}
sanitized := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
default:
return '_'
}
}, strings.ToLower(baseDB))
name := fmt.Sprintf("%s_%s", sanitized, suffix)
if len(name) > 63 {
name = name[:63]
}
u2 := *u
u2.Path = "/" + name
pkgDBMu.Lock()
cached, done := pkgDBReady[name]
pkgDBMu.Unlock()
if done {
return cached
}
if err := ensureTestDatabase(baseDSN, name); err != nil {
t.Fatalf("prepare per-package test database %s: %v", name, err)
}
dsn := u2.String()
pkgDBMu.Lock()
pkgDBReady[name] = dsn
pkgDBMu.Unlock()
return dsn return dsn
} }
// ensureTestDatabase 在基库实例上幂等创建派生库(advisory lock 防并行包竞态)。
func ensureTestDatabase(baseDSN, name string) error {
admindb, err := sql.Open("pgx", baseDSN)
if err != nil {
return err
}
defer admindb.Close()
var exists bool
if err := admindb.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname=$1)`, name).Scan(&exists); err != nil {
return err
}
if exists {
return nil
}
if _, err := admindb.Exec(`SELECT pg_advisory_lock(hashtext($1))`, name); err != nil {
return err
}
defer admindb.Exec(`SELECT pg_advisory_unlock(hashtext($1))`, name)
if err := admindb.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname=$1)`, name).Scan(&exists); err != nil {
return err
}
if exists {
return nil
}
_, err = admindb.Exec(fmt.Sprintf(`CREATE DATABASE %q`, name))
return err
}
// RefuseSharedEnvDSN fails if the DSN targets a shared env database. // RefuseSharedEnvDSN fails if the DSN targets a shared env database.
func RefuseSharedEnvDSN(t *testing.T, dsn string) { func RefuseSharedEnvDSN(t *testing.T, dsn string) {
t.Helper() t.Helper()
+26
View File
@@ -0,0 +1,26 @@
package docgov
import (
"context"
"fmt"
)
// FreezeHeadSnapshotForTest 把文档 head/review 快照指针冻结到 snapID
// (写本模块所有的 document_records),并清除引擎缓存使后续装载来自 PG。
//
// 与 docrevision.SeedContentSnapshotForTest 配合:账本 seed 内容快照(docrevision
// 生产行为),治理侧冻结指针(本模块生产行为)。供集成测试构造合法批准链。
func (e *GovernanceEngine) FreezeHeadSnapshotForTest(ctx context.Context, docID string, snapID int64) error {
if e == nil || e.pool == nil {
return fmt.Errorf("docgov: FreezeHeadSnapshotForTest requires a PG-backed engine")
}
if _, err := e.pool.Exec(ctx,
`UPDATE document_records SET review_snapshot_id=$1, head_snapshot_id=$1 WHERE doc_id=$2`,
snapID, docID); err != nil {
return fmt.Errorf("docgov: freeze head snapshot: %w", err)
}
e.memMu.Lock()
delete(e.memDocs, docID)
e.memMu.Unlock()
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package docrevision
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// SeedContentSnapshotForTest 以生产同构方式写入内容快照(本模块所有的
// document_content_blobs + document_snapshots)并返回快照 ID。
//
// 用途:集成测试构造合法的 送审→审核→批准 链——账本封快照正是本模块的
// 生产行为。document_records 上的 head/review 指针回填归 docgov
//(GovernanceEngine.FreezeHeadSnapshotForTest),两者配合使用。
func SeedContentSnapshotForTest(ctx context.Context, pool *pgxpool.Pool, docID string, actorID int64, content string) (int64, error) {
if pool == nil {
return 0, fmt.Errorf("docrevision: SeedContentSnapshotForTest requires a pool")
}
sha := fmt.Sprintf("sha_seed_%d", time.Now().UnixNano())
var snapID int64
err := pool.QueryRow(ctx, `
WITH blob AS (
INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size, created_at)
VALUES ($2, $3, $4, now())
RETURNING content_sha256
)
INSERT INTO document_snapshots (doc_id, content_sha256, actor_user_id, actor_snapshot, source_kind, created_at)
SELECT $1::text, blob.content_sha256, $5, '[]'::jsonb, 'human', now() FROM blob
RETURNING id
`, docID, sha, content, len(content), actorID).Scan(&snapID)
if err != nil {
return 0, fmt.Errorf("docrevision: seed snapshot: %w", err)
}
return snapID, nil
}
+1 -6
View File
@@ -2,7 +2,6 @@ package notification_test
import ( import (
"context" "context"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -16,11 +15,7 @@ import (
func openPool(t *testing.T) *pgxpool.Pool { func openPool(t *testing.T) *pgxpool.Pool {
t.Helper() t.Helper()
// 隔离约定:仅提供专用测试库时运行,禁止默认回退到开发库。 // 隔离约定:仅提供专用测试库时运行,禁止默认回退到开发库。
if os.Getenv("TEST_DATABASE_URL") == "" { dsn := db.SkipIfNoTestDatabaseURL(t)
t.Skip("TEST_DATABASE_URL not set; skipping notification PG test")
return nil
}
dsn := os.Getenv("TEST_DATABASE_URL")
if strings.Contains(dsn, "agent_system_dev") || strings.Contains(dsn, "agent_system_test") || strings.Contains(dsn, "agent_system_main") { if strings.Contains(dsn, "agent_system_dev") || strings.Contains(dsn, "agent_system_test") || strings.Contains(dsn, "agent_system_main") {
t.Fatal("TEST_DATABASE_URL must not point at a shared env database") t.Fatal("TEST_DATABASE_URL must not point at a shared env database")
} }
@@ -92,7 +92,7 @@ func TestPG_GoverningAccess_ActiveCreatorDutyOutsider(t *testing.T) {
code := "pg-access-live-" + time.Now().Format("150405.000") code := "pg-access-live-" + time.Now().Format("150405.000")
err = pool.QueryRow(ctx, ` err = pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'Access Live', 'active', $2, 'planning', '', 1, 0) RETURNING id VALUES ($1, 'Access Live ' || $1, 'active', $2, 'planning', '', 1, 0) RETURNING id
`, code, creatorID).Scan(&projectID) `, code, creatorID).Scan(&projectID)
if err != nil { if err != nil {
t.Fatalf("insert project: %v", err) t.Fatalf("insert project: %v", err)
@@ -128,7 +128,7 @@ func TestPG_GoverningAccess_ActiveCreatorDutyOutsider(t *testing.T) {
t.Fatalf("preview: %v", err) t.Fatalf("preview: %v", err)
} }
if _, err := svc.Activate(ctx, projectID, creatorID, true, projectinstance.ActivateInput{ if _, err := svc.Activate(ctx, projectID, creatorID, true, projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision, ExpectedPlanRevision: preview.ExpectedPlanRevision,
}); err != nil { }); err != nil {
@@ -25,8 +25,8 @@ func TestPG_ActivateOfficialWorkflowRuntimeMetadata(t *testing.T) {
t.Fatalf("load main.v2 manifest: %v", err) t.Fatalf("load main.v2 manifest: %v", err)
} }
if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{
Key: workflowdef.OfficialKeyMain, Key: workflowdef.OfficialKeyMain,
Name: "商务对接流程", Name: "商务对接流程",
Definition: def, Definition: def,
}, 1); err != nil { }, 1); err != nil {
t.Fatalf("seed official main workflow: %v", err) t.Fatalf("seed official main workflow: %v", err)
+8 -8
View File
@@ -4,14 +4,14 @@ import "context"
// DocumentApprovedResult 文档批准后阶段就绪评估结果。 // DocumentApprovedResult 文档批准后阶段就绪评估结果。
type DocumentApprovedResult struct { type DocumentApprovedResult struct {
StageReady bool StageReady bool
InstanceID int64 InstanceID int64
StageInstanceID int64 StageInstanceID int64
StageKey string StageKey string
StageName string StageName string
ConfigRevision int ConfigRevision int
RequiredDocCount int RequiredDocCount int
ApprovedDocCount int ApprovedDocCount int
} }
// HandleDocumentApproved 同步文档任务状态并评估是否首次满足阶段推进条件。 // HandleDocumentApproved 同步文档任务状态并评估是否首次满足阶段推进条件。
+1 -1
View File
@@ -20,7 +20,7 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) {
StageKey: "V0.1_triage", Enabled: true, StageKey: "V0.1_triage", Enabled: true,
DocTasks: []DocTaskPlan{{ DocTasks: []DocTaskPlan{{
TaskKey: "intake", Enabled: true, TaskKey: "intake", Enabled: true,
Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4}, Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4},
}}, }},
}}, }},
} }
@@ -187,4 +187,3 @@ func TestValidateAuthorOnlyDocTaskDuties(t *testing.T) {
t.Fatalf("separated A/A/A: %+v", sep) t.Fatalf("separated A/A/A: %+v", sep)
} }
} }
@@ -9,6 +9,7 @@ import (
"chat-agent-system/internal/db" "chat-agent-system/internal/db"
"chat-agent-system/internal/docgov" "chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/notification" "chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/projectworkflow"
@@ -151,9 +152,22 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
t.Fatalf("create doc: %v", err) t.Fatalf("create doc: %v", err)
} }
_ = engine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p3", false) _ = engine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p3", false)
_, _ = engine.SubmitForReview(ctx, docID, authorID, "author", "张") if _, err := engine.SubmitForReview(ctx, docID, authorID, "author", "张"); err != nil {
_, _ = engine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "李", "ok") t.Fatalf("submit for review: %v", err)
_, err = engine.ApproverApprove(ctx, docID, approverID, "approver", "王", "ok") }
snapID, serr := docrevision.SeedContentSnapshotForTest(ctx, pool, docID, authorID, "# pg p3 intake")
if serr != nil {
t.Fatalf("seed snapshot: %v", serr)
}
if ferr := engine.FreezeHeadSnapshotForTest(ctx, docID, snapID); ferr != nil {
t.Fatalf("freeze head snapshot: %v", ferr)
}
// 直写绕过了 docgov 引擎缓存;换新引擎使 approve 从 PG 装载快照。
freshEngine := docgov.NewGovernanceEngine(pool)
if _, err := freshEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "李", "ok"); err != nil {
t.Fatalf("review approve: %v", err)
}
_, err = freshEngine.ApproverApprove(ctx, docID, approverID, "approver", "王", "ok")
if err != nil { if err != nil {
t.Fatalf("approve doc: %v", err) t.Fatalf("approve doc: %v", err)
} }
@@ -69,7 +69,7 @@ func TestAdvanceStage_DoesNotChangeProjectOnNonLastStage(t *testing.T) {
StageKey: "V0.5_feasibility", Enabled: true, StageKey: "V0.5_feasibility", Enabled: true,
DocTasks: []DocTaskPlan{{ DocTasks: []DocTaskPlan{{
TaskKey: "feasibility_reg", Enabled: true, TaskKey: "feasibility_reg", Enabled: true,
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
}}, }},
}) })
_, err := svc.SaveWorkflowPlan(ctx, 101, 1, true, SavePlanInput{ _, err := svc.SaveWorkflowPlan(ctx, 101, 1, true, SavePlanInput{
+2 -2
View File
@@ -20,8 +20,8 @@ type TerminateResult struct {
// PendingDocSummary 终止预览中的待处理文档摘要。 // PendingDocSummary 终止预览中的待处理文档摘要。
type PendingDocSummary struct { type PendingDocSummary struct {
TaskKey string `json:"task_key"` TaskKey string `json:"task_key"`
Status string `json:"status"` Status string `json:"status"`
StageKey string `json:"stage_key,omitempty"` StageKey string `json:"stage_key,omitempty"`
} }
+1 -1
View File
@@ -155,7 +155,7 @@ func validPlan() WorkflowPlan {
StageKey: "V0.1_triage", Enabled: true, StageKey: "V0.1_triage", Enabled: true,
DocTasks: []DocTaskPlan{{ DocTasks: []DocTaskPlan{{
TaskKey: "intake", Enabled: true, TaskKey: "intake", Enabled: true,
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
}}, }},
}}, }},
} }
+1 -1
View File
@@ -15,9 +15,9 @@ import (
"chat-agent-system/internal/docgov" "chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent" "chat-agent-system/internal/intent"
"chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/textextract"
"chat-agent-system/internal/storage" "chat-agent-system/internal/storage"
"chat-agent-system/internal/stream" "chat-agent-system/internal/stream"
"chat-agent-system/internal/textextract"
"chat-agent-system/internal/workflowscope" "chat-agent-system/internal/workflowscope"
) )
@@ -2,6 +2,7 @@ package server_test
import ( import (
"bytes" "bytes"
"chat-agent-system/internal/docrevision"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -109,8 +110,19 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) {
t.Fatalf("create doc: %v", err) t.Fatalf("create doc: %v", err)
} }
_ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_e2e_te", false) _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_e2e_te", false)
_, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author") if _, err := docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author"); err != nil {
_, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok") t.Fatalf("submit review: %v", err)
}
snapID, err := docrevision.SeedContentSnapshotForTest(ctx, pool, docID, creatorID, "# e2e governance")
if err != nil {
t.Fatalf("seed snapshot: %v", err)
}
if err := docEngine.FreezeHeadSnapshotForTest(ctx, docID, snapID); err != nil {
t.Fatalf("freeze head snapshot: %v", err)
}
if _, err := docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok"); err != nil {
t.Fatalf("review approve: %v", err)
}
_, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok") _, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok")
if err != nil { if err != nil {
t.Fatalf("approve doc: %v", err) t.Fatalf("approve doc: %v", err)
+1 -1
View File
@@ -48,7 +48,7 @@ func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
if err != nil || ownerID <= 0 || ownerID != userID { if err != nil || ownerID <= 0 || ownerID != userID {
writeJSONResponse(w, http.StatusForbidden, map[string]string{ writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN", "error_code": "ERR_FORBIDDEN",
"message": "无权访问该会话上下文", "message": "无权访问该会话上下文",
}) })
return return
} }
+1 -1
View File
@@ -58,7 +58,7 @@ func TestPhase5_AuthzNegativeMatrix_PG(t *testing.T) {
creatorToken := loginTestUser(t, handler, "zhanggong", "123456") creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456") outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456")
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Phase5 Authz Matrix Test"}) createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: fmt.Sprintf("Phase5 Authz Matrix Test %d", time.Now().UnixNano())})
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
withBearerAuth(reqCreate, creatorToken) withBearerAuth(reqCreate, creatorToken)
recCreate := httptest.NewRecorder() recCreate := httptest.NewRecorder()
@@ -2,6 +2,7 @@ package server_test
import ( import (
"bytes" "bytes"
"chat-agent-system/internal/docrevision"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -108,8 +109,19 @@ func setupPGProjectReadyToAdvance(t *testing.T, pool *pgxpool.Pool, handler http
docEngine := bizdev.Snapshot(srv.BizdevService()).Gov docEngine := bizdev.Snapshot(srv.BizdevService()).Gov
_, _ = docEngine.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "Intake", "V0.1", "", creatorID, "A", reviewerID, "R", approverID, "P") _, _ = docEngine.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "Intake", "V0.1", "", creatorID, "A", reviewerID, "R", approverID, "P")
_ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_p5_fault", false) _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_p5_fault", false)
_, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author") if _, serr := docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author"); serr != nil {
_, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok") t.Fatalf("submit review: %v", serr)
}
snapID, serr := docrevision.SeedContentSnapshotForTest(ctx, pool, docID, creatorID, "# phase5")
if serr != nil {
t.Fatalf("seed snapshot: %v", serr)
}
if ferr := docEngine.FreezeHeadSnapshotForTest(ctx, docID, snapID); ferr != nil {
t.Fatalf("freeze head snapshot: %v", ferr)
}
if _, serr := docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok"); serr != nil {
t.Fatalf("review approve: %v", serr)
}
_, _ = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok") _, _ = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok")
instSvc := projectinstance.NewService(projectinstance.NewStore(pool), nil, nil, nil) instSvc := projectinstance.NewService(projectinstance.NewStore(pool), nil, nil, nil)
if _, err = instSvc.HandleDocumentApproved(ctx, projectID, "bd_intake"); err != nil { if _, err = instSvc.HandleDocumentApproved(ctx, projectID, "bd_intake"); err != nil {
+3 -1
View File
@@ -3,9 +3,11 @@ package server_test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"unicode/utf8" "unicode/utf8"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -24,7 +26,7 @@ func TestProjectCreate_PG_ChineseNameViaHTTP(t *testing.T) {
token := loginTestUser(t, handler, "zhanggong", "123456") token := loginTestUser(t, handler, "zhanggong", "123456")
body, _ := json.Marshal(projectworkflow.CreateProjectInput{ body, _ := json.Marshal(projectworkflow.CreateProjectInput{
Name: "PG Phase5 治理链中文名称测试", Name: fmt.Sprintf("PG Phase5 治理链中文名称测试 %d", time.Now().UnixNano()),
DeviceType: "超声手术设备", DeviceType: "超声手术设备",
IntendedUse: "软组织切除", IntendedUse: "软组织切除",
TargetMarket: "NMPA 三类", TargetMarket: "NMPA 三类",
@@ -1,10 +1,10 @@
package server_test package server_test
import ( import (
"chat-agent-system/internal/db"
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"testing" "testing"
"time" "time"
@@ -17,11 +17,7 @@ import (
func openPGPool(t *testing.T) *pgxpool.Pool { func openPGPool(t *testing.T) *pgxpool.Pool {
t.Helper() t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL") dsn := db.SkipIfNoTestDatabaseURL(t)
if dsn == "" {
t.Skip("TEST_DATABASE_URL must be set for PostgreSQL integration tests (refusing default dev database)")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
pool, err := pgxpool.New(ctx, dsn) pool, err := pgxpool.New(ctx, dsn)
+1 -1
View File
@@ -25,7 +25,7 @@ func TestTaskRunAuthz_OutsiderForbidden_PG(t *testing.T) {
creatorToken := loginTestUser(t, handler, "zhanggong", "123456") creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456") outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456")
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Task Run Authz Test"}) createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: fmt.Sprintf("Task Run Authz Test %d", time.Now().UnixNano())})
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
withBearerAuth(reqCreate, creatorToken) withBearerAuth(reqCreate, creatorToken)
recCreate := httptest.NewRecorder() recCreate := httptest.NewRecorder()
@@ -2,6 +2,7 @@ package server_test
import ( import (
"bytes" "bytes"
"chat-agent-system/internal/docrevision"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -226,6 +227,13 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) {
} }
_ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p5", false) _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p5", false)
_, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author") _, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author")
snapID, serr := docrevision.SeedContentSnapshotForTest(ctx, pool, docID, creatorID, "# governance pg")
if serr != nil {
t.Fatalf("seed snapshot: %v", serr)
}
if ferr := docEngine.FreezeHeadSnapshotForTest(ctx, docID, snapID); ferr != nil {
t.Fatalf("freeze head snapshot: %v", ferr)
}
_, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok") _, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok")
_, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok") _, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok")
if err != nil { if err != nil {
+5 -5
View File
@@ -30,12 +30,12 @@ type CAS struct {
embedder Embedder embedder Embedder
// 离线/降级内存索引 (当数据库未连接时) // 离线/降级内存索引 (当数据库未连接时)
mu sync.RWMutex mu sync.RWMutex
memoryBlobs map[string]*BlobInfo memoryBlobs map[string]*BlobInfo
memoryRefs map[int64]*FileReference memoryRefs map[int64]*FileReference
memoryChunks []*MemoryChunk memoryChunks []*MemoryChunk
nextRefID int64 nextRefID int64
nextChunkID int64 nextChunkID int64
} }
// NewCAS 创建 CAS 存储管理器 // NewCAS 创建 CAS 存储管理器
+7 -5
View File
@@ -1,6 +1,7 @@
package storage package storage
import ( import (
"chat-agent-system/internal/db"
"context" "context"
"os" "os"
"testing" "testing"
@@ -11,12 +12,9 @@ import (
func TestCAS_DatabaseIntegration(t *testing.T) { func TestCAS_DatabaseIntegration(t *testing.T) {
// 隔离优先:TEST_DATABASE_URL 指向专用测试库;未提供时跳过,禁止默认连开发库。 // 隔离优先:TEST_DATABASE_URL 指向专用测试库;未提供时跳过,禁止默认连开发库。
dsn := os.Getenv("TEST_DATABASE_URL") dsn := db.SkipIfNoTestDatabaseURL(t)
if dsn == "" { if dsn == "" {
dsn = os.Getenv("CONTEXT_DB_DSN") t.Skip("TEST_DATABASE_URL not set; skipping CAS DB integration test")
}
if dsn == "" {
t.Skip("TEST_DATABASE_URL/CONTEXT_DB_DSN not set (refusing default dev database); skipping CAS DB integration test")
return return
} }
@@ -30,6 +28,10 @@ func TestCAS_DatabaseIntegration(t *testing.T) {
} }
defer pool.Close() defer pool.Close()
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migration failed: %v", err)
}
if err := pool.Ping(ctx); err != nil { if err := pool.Ping(ctx); err != nil {
t.Skipf("context db ping failed (%v), skipping DB integration test", err) t.Skipf("context db ping failed (%v), skipping DB integration test", err)
return return
+4 -4
View File
@@ -70,8 +70,8 @@ type MemorySearchOptions struct {
// MemoryChunkHit 记忆检索命中结果 // MemoryChunkHit 记忆检索命中结果
type MemoryChunkHit struct { type MemoryChunkHit struct {
Chunk *MemoryChunk `json:"chunk"` Chunk *MemoryChunk `json:"chunk"`
RawScore float64 `json:"raw_score"` RawScore float64 `json:"raw_score"`
TimeDecay float64 `json:"time_decay"` TimeDecay float64 `json:"time_decay"`
FinalScore float64 `json:"final_score"` FinalScore float64 `json:"final_score"`
} }
+1 -6
View File
@@ -2,7 +2,6 @@ package taskrun_test
import ( import (
"context" "context"
"os"
"testing" "testing"
"time" "time"
@@ -14,11 +13,7 @@ import (
func openLeaseTestPool(t *testing.T) *pgxpool.Pool { func openLeaseTestPool(t *testing.T) *pgxpool.Pool {
t.Helper() t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL") dsn := db.SkipIfNoTestDatabaseURL(t)
if dsn == "" {
t.Skip("TEST_DATABASE_URL must be set for PostgreSQL integration tests (refusing default dev database)")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
pool, err := pgxpool.New(ctx, dsn) pool, err := pgxpool.New(ctx, dsn)
+4 -7
View File
@@ -1,9 +1,9 @@
package workspace package workspace
import ( import (
"chat-agent-system/internal/db"
"context" "context"
"fmt" "fmt"
"os"
"testing" "testing"
"time" "time"
@@ -12,14 +12,11 @@ import (
) )
func TestPGRegistryRoundTrip(t *testing.T) { func TestPGRegistryRoundTrip(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL") dsn := db.SkipIfNoTestDatabaseURL(t)
if url == "" {
t.Skip("set TEST_DATABASE_URL to run the workspace registry postgres test")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
schema := fmt.Sprintf("agentx_reg_%d", time.Now().UnixNano()) schema := fmt.Sprintf("agentx_reg_%d", time.Now().UnixNano())
boot, err := pgxpool.New(ctx, url) boot, err := pgxpool.New(ctx, dsn)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -32,7 +29,7 @@ func TestPGRegistryRoundTrip(t *testing.T) {
defer ccancel() defer ccancel()
_, _ = boot.Exec(cctx, "DROP SCHEMA "+schema+" CASCADE") _, _ = boot.Exec(cctx, "DROP SCHEMA "+schema+" CASCADE")
}) })
cfg, err := pgxpool.ParseConfig(url) cfg, err := pgxpool.ParseConfig(dsn)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }