//go:build dbtest // redis_integration_test.go — RedisClient 對「真 Redis」的整合測試。 // // owner: testing agent(internal/db redis 補測) // // build tag `dbtest`:預設 `go test ./...` 不編譯本檔。 // 執行方式(二擇一,對齊 internal/usersession/redis_integration_test.go 既有 pattern): // // 1. 連既有 Redis(例如 130 上的 visiona-redis): // VISIONA_TEST_REDIS_ADDR=visiona-redis:6379 go test -tags=dbtest ./internal/db/... // // 2. testcontainers 自動起一次性 Redis(需 Docker daemon;本機無 docker 時用 // DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true 指向 130): // go test -tags=dbtest ./internal/db/... // // 本檔與 redis_test.go(miniredis,預設可跑)互補:miniredis 驗連線/ping/close 程式邏輯, // 本檔驗「NewRedisClient 對真 Redis 的 ping / graceful close / Client() 真的可用」。 package db import ( "context" "net" "os" "strconv" "testing" "time" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "visiona-backend/internal/config" ) // realRedisAddr 回傳一個可連的真 Redis host:port: // - 若設了 VISIONA_TEST_REDIS_ADDR → 直接用(130 補跑路徑)。 // - 否則 → testcontainers 起一次性 redis:7-alpine。 func realRedisAddr(t *testing.T) string { t.Helper() if addr := os.Getenv("VISIONA_TEST_REDIS_ADDR"); addr != "" { return addr } 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) } return host + ":" + port.Port() } // realRedisConfig 把 host:port 拆回 RedisConfig 餵 NewRedisClient。 func realRedisConfig(t *testing.T, addr string) config.RedisConfig { t.Helper() host, portStr, err := net.SplitHostPort(addr) require.NoError(t, err, "解析 real redis addr=%s", addr) port, err := strconv.Atoi(portStr) require.NoError(t, err) return config.RedisConfig{ Host: host, Port: port, Password: os.Getenv("VISIONA_TEST_REDIS_PASSWORD"), DB: 0, ConnTimeout: 5 * time.Second, } } // TestRealRedis_NewClientConnectsAndPings 驗證 NewRedisClient 對真 Redis: // 建連成功、啟動 ping 通過、Client() 可實際操作、Ping() 可重複呼叫。 func TestRealRedis_NewClientConnectsAndPings(t *testing.T) { cfg := realRedisConfig(t, realRedisAddr(t)) rc, err := NewRedisClient(context.Background(), cfg, nil) require.NoError(t, err, "真 Redis 可達時應成功建連") require.NotNil(t, rc) t.Cleanup(rc.Close) // Client() 可實際操作真 Redis(SET/GET round-trip)。 ctx := context.Background() require.NoError(t, rc.Client().Set(ctx, "db_redis_it_key", "v1", time.Minute).Err()) got, err := rc.Client().Get(ctx, "db_redis_it_key").Result() require.NoError(t, err) assert.Equal(t, "v1", got) t.Cleanup(func() { _ = rc.Client().Del(context.Background(), "db_redis_it_key").Err() }) // Ping 可重複呼叫(health check 場景)。 require.NoError(t, rc.Ping(ctx)) require.NoError(t, rc.Ping(ctx)) } // TestRealRedis_GracefulClose 驗證 Close 後底層 client 不可再用(graceful close 生效)。 func TestRealRedis_GracefulClose(t *testing.T) { cfg := realRedisConfig(t, realRedisAddr(t)) rc, err := NewRedisClient(context.Background(), cfg, nil) require.NoError(t, err) rc.Close() // Close 後 Ping 應失敗(go-redis 回 "client is closed")。 assert.Error(t, rc.Ping(context.Background()), "graceful close 後 Ping 應失敗") } // TestRealRedis_WrongDBStillConnects 驗證非 0 的 DB index 也能正常建連(DB 欄位有被套用)。 func TestRealRedis_WrongDBStillConnects(t *testing.T) { cfg := realRedisConfig(t, realRedisAddr(t)) cfg.DB = 1 // 切到 db1 rc, err := NewRedisClient(context.Background(), cfg, nil) require.NoError(t, err) t.Cleanup(rc.Close) // 在 db1 寫一筆,確認 SELECT 1 真的生效(不會誤落 db0)。 ctx := context.Background() require.NoError(t, rc.Client().Set(ctx, "db1_only_key", "x", time.Minute).Err()) n, err := rc.Client().Exists(ctx, "db1_only_key").Result() require.NoError(t, err) assert.Equal(t, int64(1), n) t.Cleanup(func() { _ = rc.Client().Del(context.Background(), "db1_only_key").Err() }) // 另開一個 db0 client,確認 db1 的 key 不可見(DB 隔離正確)。 db0 := redis.NewClient(&redis.Options{ Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), Password: cfg.Password, DB: 0, }) t.Cleanup(func() { _ = db0.Close() }) n0, err := db0.Exists(ctx, "db1_only_key").Result() require.NoError(t, err) assert.Equal(t, int64(0), n0, "db1 的 key 不應在 db0 可見(DB 隔離)") }