把 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>
133 lines
4.6 KiB
Go
133 lines
4.6 KiB
Go
//go:build dbtest
|
||
|
||
// WithTx 交易 helper 的真 DB 整合測試(DB 接入塊 5.1 / 5.5)。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。
|
||
// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。
|
||
//
|
||
// 用 package db_test(外部測試包)避免 testsupport → db 的 import cycle(對齊 db_integration_test.go)。
|
||
//
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/db/...
|
||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/db/...
|
||
//
|
||
// 涵蓋:commit 落地、fn 回 error 整筆 rollback、DB 錯誤 rollback、panic rollback、context 取消、nil pool。
|
||
package db_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/db"
|
||
"visiona-backend/internal/db/testsupport"
|
||
)
|
||
|
||
func countUsersTx(t *testing.T, tdb *testsupport.TestDB) int {
|
||
t.Helper()
|
||
var n int
|
||
require.NoError(t, tdb.Pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n))
|
||
return n
|
||
}
|
||
|
||
func insertUserTx(ctx context.Context, q db.Querier, id, email string) error {
|
||
_, err := q.Exec(ctx, `INSERT INTO users (id, email) VALUES ($1, $2)`, id, email)
|
||
return err
|
||
}
|
||
|
||
// TestWithTx_CommitsOnSuccess 驗證 fn 成功時變更落地(commit)。
|
||
func TestWithTx_CommitsOnSuccess(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "users")
|
||
|
||
id := uuid.NewString()
|
||
err := db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error {
|
||
return insertUserTx(context.Background(), q, id, id+"@t.local")
|
||
})
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 1, countUsersTx(t, tdb), "commit 後該列應存在")
|
||
}
|
||
|
||
// TestWithTx_RollbackOnError 驗證 fn 回 error 時整筆 rollback(中途已寫入的列也回滾)。
|
||
func TestWithTx_RollbackOnError(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "users")
|
||
|
||
sentinel := errors.New("boom")
|
||
err := db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error {
|
||
if e := insertUserTx(context.Background(), q, uuid.NewString(), "a@t.local"); e != nil {
|
||
return e
|
||
}
|
||
return sentinel
|
||
})
|
||
require.ErrorIs(t, err, sentinel, "WithTx 應原樣回傳 fn 的 error")
|
||
assert.Equal(t, 0, countUsersTx(t, tdb), "rollback 後第一列也不該存在(原子性)")
|
||
}
|
||
|
||
// TestWithTx_RollbackOnDBError 驗證 fn 內 DB 錯誤(unique violation)→ rollback。
|
||
func TestWithTx_RollbackOnDBError(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "users")
|
||
|
||
email := "dup@t.local"
|
||
_, err := tdb.Pool.Exec(context.Background(),
|
||
`INSERT INTO users (id, email) VALUES ($1, $2)`, uuid.NewString(), email)
|
||
require.NoError(t, err)
|
||
|
||
err = db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error {
|
||
if e := insertUserTx(context.Background(), q, uuid.NewString(), "ok@t.local"); e != nil {
|
||
return e
|
||
}
|
||
return insertUserTx(context.Background(), q, uuid.NewString(), email) // 撞 unique
|
||
})
|
||
require.Error(t, err)
|
||
assert.Equal(t, 1, countUsersTx(t, tdb), "交易外那筆仍在,交易內兩筆 rollback")
|
||
var okCount int
|
||
require.NoError(t, tdb.Pool.QueryRow(context.Background(),
|
||
`SELECT count(*) FROM users WHERE email = 'ok@t.local'`).Scan(&okCount))
|
||
assert.Equal(t, 0, okCount, "交易內第一列也應 rollback")
|
||
}
|
||
|
||
// TestWithTx_PanicRollsBackAndRepanics 驗證 fn panic 時 rollback 並重新 panic(不吞)。
|
||
func TestWithTx_PanicRollsBackAndRepanics(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "users")
|
||
|
||
assert.Panics(t, func() {
|
||
_ = db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error {
|
||
_ = insertUserTx(context.Background(), q, uuid.NewString(), "panic@t.local")
|
||
panic("kaboom")
|
||
})
|
||
})
|
||
assert.Equal(t, 0, countUsersTx(t, tdb), "panic 後該列應 rollback")
|
||
}
|
||
|
||
// TestWithTx_ContextCanceled 驗證已取消的 context → Begin / 查詢失敗、無半開交易。
|
||
func TestWithTx_ContextCanceled(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "users")
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel()
|
||
|
||
err := db.WithTx(ctx, tdb.Pool, func(q db.Querier) error {
|
||
return insertUserTx(ctx, q, uuid.NewString(), "cancel@t.local")
|
||
})
|
||
require.Error(t, err)
|
||
assert.Equal(t, 0, countUsersTx(t, tdb))
|
||
}
|
||
|
||
// TestWithTx_NilPool 驗證 nil pool 回 error(不 panic)。此測試不需真 DB,但置於 dbtest 檔
|
||
// 共用 build tag;純驗 nil 防護。
|
||
func TestWithTx_NilPool(t *testing.T) {
|
||
err := db.WithTx(context.Background(), nil, func(q db.Querier) error { return nil })
|
||
require.Error(t, err)
|
||
}
|