把 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>
170 lines
5.7 KiB
Go
170 lines
5.7 KiB
Go
//go:build dbtest
|
||
|
||
// DB 接入塊 4(4.6 真 Redis 部分):RedisUserSessionStore 對「真 Redis」的整合測試。
|
||
//
|
||
// build tag `dbtest`:預設 `go test ./...` 不編譯本檔。
|
||
// 執行方式(二擇一):
|
||
//
|
||
// 1. 連既有 Redis(例如 130 上的 visiona-redis,從 130 內部或經 DOCKER_HOST 轉發):
|
||
// VISIONA_TEST_REDIS_ADDR=visiona-redis:6379 go test -tags=dbtest ./internal/usersession/...
|
||
// (無密碼;若有密碼設 VISIONA_TEST_REDIS_PASSWORD)
|
||
//
|
||
// 2. 用 testcontainers 自動起一次性 Redis(需 Docker daemon;本機無 docker 時用
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 指向 130 的 docker):
|
||
// go test -tags=dbtest ./internal/usersession/...
|
||
//
|
||
// 本檔與 redis_test.go(miniredis,預設可跑)互補:miniredis 已驗雙 TTL 邏輯,
|
||
// 本檔驗「真 Redis 真的會在 TTL 到期時清掉 key」+ 序列化 round-trip 在真 Redis 下成立。
|
||
package usersession
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"os"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
"github.com/testcontainers/testcontainers-go"
|
||
"github.com/testcontainers/testcontainers-go/wait"
|
||
)
|
||
|
||
// realRedisClient 取得連到真 Redis 的 client:
|
||
// - 若設了 VISIONA_TEST_REDIS_ADDR → 直連該位址(130 visiona-redis 補跑路徑)。
|
||
// - 否則 → testcontainers 起一次性 redis:7-alpine。
|
||
func realRedisClient(t *testing.T) *redis.Client {
|
||
t.Helper()
|
||
|
||
if addr := os.Getenv("VISIONA_TEST_REDIS_ADDR"); addr != "" {
|
||
client := redis.NewClient(&redis.Options{
|
||
Addr: addr,
|
||
Password: os.Getenv("VISIONA_TEST_REDIS_PASSWORD"),
|
||
})
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := client.Ping(ctx).Err(); err != nil {
|
||
t.Fatalf("ping VISIONA_TEST_REDIS_ADDR=%s: %v", addr, err)
|
||
}
|
||
t.Cleanup(func() { _ = client.Close() })
|
||
return client
|
||
}
|
||
|
||
ctx := context.Background()
|
||
req := testcontainers.ContainerRequest{
|
||
Image: "redis:7-alpine",
|
||
ExposedPorts: []string{"6379/tcp"},
|
||
WaitingFor: wait.ForListeningPort("6379/tcp").WithStartupTimeout(60 * time.Second),
|
||
}
|
||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||
ContainerRequest: req,
|
||
Started: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("start redis container: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = container.Terminate(ctx) })
|
||
|
||
host, err := container.Host(ctx)
|
||
if err != nil {
|
||
t.Fatalf("container host: %v", err)
|
||
}
|
||
port, err := container.MappedPort(ctx, "6379/tcp")
|
||
if err != nil {
|
||
t.Fatalf("container port: %v", err)
|
||
}
|
||
client := redis.NewClient(&redis.Options{Addr: host + ":" + port.Port()})
|
||
t.Cleanup(func() { _ = client.Close() })
|
||
return client
|
||
}
|
||
|
||
// TestRealRedis_RoundTripAndIsolation 驗證所有欄位在真 Redis 下序列化 round-trip 正確,
|
||
// 且 key 帶 prefix、互不干擾。
|
||
func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
|
||
client := realRedisClient(t)
|
||
store := NewRedisUserSessionStore(client, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
|
||
sess, err := store.Create(ctx)
|
||
if err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
sess.UserID = "u-real"
|
||
sess.Email = "real@example.com"
|
||
sess.OIDCCodeVerifier = "cv-secret"
|
||
sess.AccessToken = "at-secret"
|
||
sess.Extra = map[string]any{"return_to": "/x", "n": float64(7)}
|
||
if err := store.Update(ctx, sess); err != nil {
|
||
t.Fatalf("Update: %v", err)
|
||
}
|
||
|
||
got, err := store.Get(ctx, sess.ID)
|
||
if err != nil {
|
||
t.Fatalf("Get: %v", err)
|
||
}
|
||
if got.UserID != "u-real" || got.Email != "real@example.com" ||
|
||
got.OIDCCodeVerifier != "cv-secret" || got.AccessToken != "at-secret" {
|
||
t.Fatalf("round-trip mismatch: %+v", got)
|
||
}
|
||
if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) {
|
||
t.Fatalf("Extra round-trip mismatch: %+v", got.Extra)
|
||
}
|
||
|
||
// key 帶 prefix(用底層 client 直接驗)。
|
||
if n, _ := client.Exists(ctx, redisKey(sess.ID)).Result(); n != 1 {
|
||
t.Fatalf("expected prefixed key to exist")
|
||
}
|
||
}
|
||
|
||
// TestRealRedis_TTLExpiry 驗證真 Redis 會在短 idle TTL 到期後自動清掉 key。
|
||
//
|
||
// 用很短的 idle(2s)讓測試在合理時間內完成(不需 FastForward;真 Redis 用真時鐘)。
|
||
func TestRealRedis_TTLExpiry(t *testing.T) {
|
||
client := realRedisClient(t)
|
||
store := NewRedisUserSessionStore(client, 2*time.Second, 168*time.Hour)
|
||
ctx := context.Background()
|
||
|
||
sess, err := store.Create(ctx)
|
||
if err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
// 立刻拿得到。
|
||
if _, err := store.Get(ctx, sess.ID); err != nil {
|
||
t.Fatalf("Get right after create: %v", err)
|
||
}
|
||
|
||
// 等超過 idle TTL(2s)+ 緩衝。
|
||
time.Sleep(3 * time.Second)
|
||
|
||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("session should be TTL-expired on real redis, got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestRealRedis_AbsoluteCapsIdle 驗證真 Redis 下 absolute 上限封頂 idle 續期:
|
||
// idle 10s、absolute 3s → 即使一直 Update,3s 後仍應消失。
|
||
func TestRealRedis_AbsoluteCapsIdle(t *testing.T) {
|
||
client := realRedisClient(t)
|
||
store := NewRedisUserSessionStore(client, 10*time.Second, 3*time.Second)
|
||
ctx := context.Background()
|
||
|
||
sess, err := store.Create(ctx)
|
||
if err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
|
||
// 每 1s Update 一次,共 4 次(idle 永遠新,但 absolute 3s 會砍)。
|
||
deadline := time.Now().Add(4 * time.Second)
|
||
var lastErr error
|
||
for time.Now().Before(deadline) {
|
||
time.Sleep(1 * time.Second)
|
||
lastErr = store.Update(ctx, sess)
|
||
}
|
||
// 最後一次 Update 落在 absolute 後 → ErrNoSession;或 Get 確認已清。
|
||
if lastErr != nil && !errors.Is(lastErr, ErrNoSession) {
|
||
t.Fatalf("unexpected Update error: %v", lastErr)
|
||
}
|
||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("session should be gone after absolute deadline, got %v", err)
|
||
}
|
||
}
|