visionA/visionA-backend/internal/db/db_integration_test.go
jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

148 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build dbtest
// DB 接入塊 00.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"))
}