//go:build dbtest // Package testsupport 提供 DB 整合測試的共用基礎建設(testcontainers-go + Postgres)。 // // DB 接入塊 0(0.6):一次性 Postgres 容器、自動 migrate、truncate helper、fixture factory。 // 塊 1–3 的 *postgres_repository_test.go 都會 import 本套件,共用同一套 setupTestDB 與 builder。 // // build tag `dbtest`:本套件與依賴它的測試只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker)。 // 預設 `go test ./...`(無 Docker 環境 / CI 無 docker daemon)不會觸碰,維持綠燈。 // // 執行方式: // // go test -tags=dbtest ./... # 需要本機 / CI 有可用的 Docker daemon // make test-db # Makefile 包裝 package testsupport import ( "context" "io" "log/slog" "net/url" "strconv" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" "visiona-backend/internal/config" "visiona-backend/internal/db" ) // discardLogger 是丟棄輸出的 logger,避免測試噪音淹沒 go test 輸出。 var discardLogger = slog.New(slog.NewTextHandler(io.Discard, nil)) const ( testDBName = "visiona_test" testDBUser = "visiona" testDBPassword = "visiona-test-pw" // 測試容器專用、非真實憑證 testImage = "postgres:14.23-alpine" ) // TestDB 是一個已啟動 + 已 migrate 的測試用 Postgres 容器與其連線池。 type TestDB struct { Pool *pgxpool.Pool Cfg config.DatabaseConfig container *tcpostgres.PostgresContainer } // SetupTestDB 啟動一個一次性 Postgres 容器、跑 migrate up、回傳已就緒的連線池。 // // 自動在 t.Cleanup 註冊容器與連線池的關閉,呼叫端不需手動 teardown。 // 容器映像固定 postgres:14.23-alpine 對齊 stage(PG 14.23),確保 gen_random_uuid 等 // 版本相依行為與正式環境一致。 func SetupTestDB(t *testing.T) *TestDB { t.Helper() ctx := context.Background() container, err := tcpostgres.Run(ctx, testImage, tcpostgres.WithDatabase(testDBName), tcpostgres.WithUsername(testDBUser), tcpostgres.WithPassword(testDBPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(60*time.Second), ), ) require.NoError(t, err, "start postgres container") // 用 ConnectionString 取得 ready DSN,再解析出 host/port 回填 config // (避免綁定特定版本的 MappedPort 回傳型別 API)。 connStr, err := container.ConnectionString(ctx, "sslmode=disable") require.NoError(t, err, "container connection string") u, err := url.Parse(connStr) require.NoError(t, err, "parse connection string") host := u.Hostname() port, err := strconv.Atoi(u.Port()) require.NoError(t, err, "parse mapped port from %q", connStr) cfg := config.DatabaseConfig{ Host: host, Port: port, User: testDBUser, Password: testDBPassword, DBName: testDBName, SSLMode: "disable", // testcontainers 本機連線,無 TLS MaxConns: 5, MinConns: 1, ConnTimeout: 10 * time.Second, AutoMigrate: true, } // 跑 migration(用與 production 相同的 RunMigrations + 嵌入式 migration)。 require.NoError(t, db.RunMigrations(cfg, discardLogger), "run migrations") pool, err := db.NewPool(ctx, cfg, discardLogger) require.NoError(t, err, "create pool") tdb := &TestDB{Pool: pool.Pool(), Cfg: cfg, container: container} t.Cleanup(func() { pool.Close() // 容器 teardown 用獨立 context,避免測試 ctx 已取消。 termCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() _ = container.Terminate(termCtx) }) return tdb } // Truncate 清空指定 table(測試間隔離用)。RESTART IDENTITY + CASCADE 確保關聯表一併清。 // // 用法:在每個子測試開頭呼叫 tdb.Truncate(t, "models", "users") 以保證乾淨起點。 // 順序無所謂(CASCADE 處理 FK)。 func (tdb *TestDB) Truncate(t *testing.T, tables ...string) { t.Helper() if len(tables) == 0 { return } ctx := context.Background() sql := "TRUNCATE TABLE " for i, tbl := range tables { if i > 0 { sql += ", " } sql += pgIdent(tbl) } sql += " RESTART IDENTITY CASCADE" _, err := tdb.Pool.Exec(ctx, sql) require.NoError(t, err, "truncate %v", tables) } // pgIdent 對 SQL identifier 做最小化的雙引號 quote(測試用,table 名稱來自測試常數、非使用者輸入)。 // 內嵌雙引號被 escape 為 "",符合 PostgreSQL identifier 規則。 func pgIdent(name string) string { out := make([]byte, 0, len(name)+2) out = append(out, '"') for i := 0; i < len(name); i++ { if name[i] == '"' { out = append(out, '"') } out = append(out, name[i]) } out = append(out, '"') return string(out) }