visionA/visionA-backend/internal/usersession/redis_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

170 lines
5.7 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 接入塊 44.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.gominiredis預設可跑互補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。
//
// 用很短的 idle2s讓測試在合理時間內完成不需 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 TTL2s+ 緩衝。
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 → 即使一直 Update3s 後仍應消失。
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)
}
}