Files
Agent-System/internal/db/testdb.go
T
agent-bot ee1e53b510
CI / go-vet (pull_request) Failing after 10s
CI / backup-restore-drill (pull_request) Failing after 25s
CI / web (pull_request) Successful in 33s
fix(tests): per-package test databases + legitimate approval fixtures (issue #2 close)
Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
   execution - migrations/seeds/truncates raced (nondeterministic failure
   sets, locally and in CI).
   Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
   per-package database (<base>_<pkgHash>, advisory-lock-guarded
   CREATE); 9 raw-env straggler tests funnelled through the helpers;
   db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
   only honors head_snapshot_id, fake content hashes never froze a
   snapshot, so ApproverApprove rejected with 'approved requires frozen
   review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
   Fix (ownership-respecting, arch-enforced):
   - docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
   - docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
     pointers on document_records (docgov-owned) and clears engine cache
   - all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
   uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
   having migrated the shared DB): explicit RunMigrationsOnceForTest.

Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
2026-09-29 09:54:43 +00:00

162 lines
4.4 KiB
Go

package db
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
migrateOnceMu sync.Mutex
migratedDSNs = map[string]error{}
)
// RunMigrationsOnceForTest 同一 DSN 只迁一次,避免并行包抢同一隔离库。
func RunMigrationsOnceForTest(ctx context.Context, pool *pgxpool.Pool, dsn string) error {
key := strings.TrimSpace(dsn)
migrateOnceMu.Lock()
defer migrateOnceMu.Unlock()
if err, ok := migratedDSNs[key]; ok {
return err
}
err := RunMigrations(ctx, pool, ResolveMigrationsDir())
migratedDSNs[key] = err
return err
}
const testDatabaseEnv = "TEST_DATABASE_URL"
// RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。
// 返回按调用方包派生的独立数据库(<base>_<pkgHash>):包内共享、包间隔离,
// 消除 go test 并行包对同一库的迁移/种子互相干扰(2026-09-29 issue #2)。
func RequireTestDatabaseURL(t *testing.T) string {
t.Helper()
dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv))
if dsn == "" {
t.Fatalf("%s must be set for PostgreSQL integration tests (refusing default dev database)", testDatabaseEnv)
}
return derivedPackageDSN(t, dsn)
}
// SkipIfNoTestDatabaseURL 无隔离测试库时跳过(单元测试包内可选 PG 用例)。
// 同样返回按包派生库。
func SkipIfNoTestDatabaseURL(t *testing.T) string {
t.Helper()
dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv))
if dsn == "" {
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
}
// 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.
func RefuseSharedEnvDSN(t *testing.T, dsn string) {
t.Helper()
if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") ||
(strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test")) ||
strings.Contains(dsn, "5435") {
t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main")
}
}