//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) } }