把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
4.9 KiB
Go
152 lines
4.9 KiB
Go
//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)
|
||
}
|