把 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>
148 lines
5.5 KiB
Go
148 lines
5.5 KiB
Go
//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"))
|
||
}
|