//go:build dbtest // DB 接入塊 0(0.8):連線池 / migration 本身的整合測試,走 testcontainers(真 Postgres 14.23)。 // // build tag `dbtest`:需要 Docker daemon。預設 `go test ./...` 不編譯本檔。 // 執行:go test -tags=dbtest ./internal/db/... package db_test import ( "context" "io" "log/slog" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/config" "visiona-backend/internal/db" "visiona-backend/internal/db/testsupport" ) func discardLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // TestPool_PingAfterSetup 驗證 setupTestDB 後連線池可 ping 通。 func TestPool_PingAfterSetup(t *testing.T) { tdb := testsupport.SetupTestDB(t) require.NoError(t, tdb.Pool.Ping(context.Background())) } // TestMigrate_Idempotent 驗證 migrate up 冪等:跑第二次為 no-change、版本不變、not dirty。 // // 不寫死版本號——冪等的本質是「再 up 一次 version 不變」,不是「version == 某固定數字」。 // 以 SetupTestDB 跑完第一次 up 後的版本當基準,驗第二次 up 後版本與其一致。 // 如此未來再加 0004/0005 也不會壞。 func TestMigrate_Idempotent(t *testing.T) { tdb := testsupport.SetupTestDB(t) // SetupTestDB 已跑過一次 up,記下當下版本當基準(= 最新可用 migration 版本)。 mg, err := db.NewMigrator(tdb.Cfg, discardLog()) require.NoError(t, err) defer mg.Close() baseVer, dirty, err := mg.Version() require.NoError(t, err) require.False(t, dirty, "第一次 up 後不應 dirty") require.Greater(t, baseVer, uint(0), "第一次 up 後版本應 > 0") // 再跑一次 up 應為 no-change(不報錯)。 require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog())) ver, dirty, err := mg.Version() require.NoError(t, err) assert.False(t, dirty, "schema 不應處於 dirty 狀態") assert.Equal(t, baseVer, ver, "再 up 一次版本不應改變(冪等)") } // TestMigrate_UpDownUp 驗證 down 後 up 仍可回到原狀態(雙向 migration 正確)。 // // 注意:Migrator.Down() 實作為 m.Steps(-1),是「回退一個版本」而非「回滾全部」。 // 因此本測試驗的是版本層級的可逆性:down 後版本 -1、再 up 後版本回到原值且 not dirty。 // 不依賴特定 migration 建立的表名 / 版本號,未來再加 0004/0005 也不會壞。 func TestMigrate_UpDownUp(t *testing.T) { tdb := testsupport.SetupTestDB(t) mg, err := db.NewMigrator(tdb.Cfg, discardLog()) require.NoError(t, err) defer mg.Close() // SetupTestDB 已 up 到最新,記下原始版本。 topVer, dirty, err := mg.Version() require.NoError(t, err) require.False(t, dirty, "起始不應 dirty") require.Greater(t, topVer, uint(0), "起始版本應 > 0") // down 一步(Steps(-1):只回退最新一個 migration)。 require.NoError(t, mg.Down()) downVer, dirty, err := mg.Version() require.NoError(t, err) assert.False(t, dirty, "down 後不應 dirty") assert.Equal(t, topVer-1, downVer, "down 一步後版本應 -1") // 再 up 一次應回到原始最新版本(雙向可逆)。 require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog())) upVer, dirty, err := mg.Version() require.NoError(t, err) assert.False(t, dirty, "重新 up 後不應 dirty") assert.Equal(t, topVer, upVer, "重新 up 後版本應回到原始最新版本") } // TestMigrate_SchemaShape 抽查 0001 的關鍵 schema 特性(gen_random_uuid 預設、FK、partial index)。 func TestMigrate_SchemaShape(t *testing.T) { tdb := testsupport.SetupTestDB(t) ctx := context.Background() // users.id 預設應能用 gen_random_uuid()(PG14 內建)插入不指定 id。 var uid string err := tdb.Pool.QueryRow(ctx, `INSERT INTO users (email) VALUES ($1) RETURNING id`, "shape@test.local").Scan(&uid) require.NoError(t, err, "insert user with default uuid") assert.NotEmpty(t, uid) // models.owner_user_id FK:插入不存在的 owner 應失敗。 _, err = tdb.Pool.Exec(ctx, `INSERT INTO models (owner_user_id, name, storage_key, file_size, source) VALUES ($1, $2, $3, $4, $5)`, "99999999-9999-9999-9999-999999999999", "m", "k", 1, "uploaded") assert.Error(t, err, "FK 違反應報錯") // 用合法 owner 插入應成功,array 欄位 round-trip。 var mid string err = tdb.Pool.QueryRow(ctx, `INSERT INTO models (owner_user_id, name, storage_key, file_size, source, classes, input_shape) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, uid, "m1", "models/x.nef", 123, "uploaded", []string{"cat", "dog"}, []int32{1, 224, 224, 3}).Scan(&mid) require.NoError(t, err) assert.NotEmpty(t, mid) } // TestPool_FailFast 驗證連到不存在的 DB 會 fail-fast(建池/ping 回 error,不 hang)。 func TestPool_FailFast(t *testing.T) { cfg := config.DatabaseConfig{ Host: "127.0.0.1", Port: 1, // 不可能有 postgres 監聽的 port User: "nobody", Password: "nopw", DBName: "nodb", SSLMode: "disable", ConnTimeout: 2 * time.Second, } start := time.Now() _, err := db.NewPool(context.Background(), cfg, discardLog()) require.Error(t, err, "連不上 DB 應回 error") assert.Less(t, time.Since(start), 10*time.Second, "應在 ConnTimeout 內 fail-fast,不 hang") } // TestTruncate 驗證 truncate helper 清空資料。 func TestTruncate(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.EnsureDemoUser(t) assert.Equal(t, 1, tdb.CountRows(t, "users")) tdb.Truncate(t, "models", "users") assert.Equal(t, 0, tdb.CountRows(t, "users")) }