// redis_test.go — RedisClient 的本機可跑測試(不需 docker)。 // // owner: testing agent(internal/db redis 補測) // // 分類: // - SafeRedisTarget:純函式 unit(log 安全字串、不含密碼) // - NewRedisClient / Ping / Close / Client:用 in-process miniredis(純 Go,不需 docker) // 驗證連線建立、啟動 ping、graceful close、底層 client 取得。 // - fail-fast:連不上的 host 應立即回 error(NewRedisClient 不靜默 fallback)。 // // 與 redis_integration_test.go(//go:build dbtest,真 Redis)互補: // 本檔用 miniredis 驗「連線/ping/close 的程式邏輯」,dbtest 驗「真 Redis parity」。 package db import ( "context" "net" "strconv" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/config" ) // miniredisConfig 起一個 in-process miniredis 並回傳對應的 RedisConfig // (把 mr.Addr() 的 host:port 拆回 Host / Port 欄位,餵給 NewRedisClient)。 func miniredisConfig(t *testing.T) config.RedisConfig { t.Helper() mr := miniredis.RunT(t) // RunT 會在 t.Cleanup 自動 Close host, portStr, err := net.SplitHostPort(mr.Addr()) require.NoError(t, err, "解析 miniredis addr") port, err := strconv.Atoi(portStr) require.NoError(t, err, "解析 miniredis port") return config.RedisConfig{ Host: host, Port: port, DB: 0, ConnTimeout: 2 * time.Second, } } // --------------------------------------------------------------------------- // SafeRedisTarget — 純函式 unit(happy / 預設值 / 不含密碼) // --------------------------------------------------------------------------- func TestSafeRedisTarget_FormatsHostPortDB(t *testing.T) { got := SafeRedisTarget(config.RedisConfig{Host: "10.0.0.5", Port: 6380, DB: 3}) assert.Equal(t, "10.0.0.5:6380/3", got) } func TestSafeRedisTarget_DefaultsPortWhenZero(t *testing.T) { // 邊界值:Port=0 應回退預設 6379(對齊 NewRedisClient 行為)。 got := SafeRedisTarget(config.RedisConfig{Host: "redis", Port: 0, DB: 0}) assert.Equal(t, "redis:6379/0", got) } func TestSafeRedisTarget_NeverContainsPassword(t *testing.T) { // 安全:即使 config 帶密碼,SafeRedisTarget 也不得洩漏。 cfg := config.RedisConfig{Host: "redis", Port: 6379, DB: 0, Password: "super-secret-pw"} got := SafeRedisTarget(cfg) assert.NotContains(t, got, "super-secret-pw", "log 安全字串不得含密碼") assert.Equal(t, "redis:6379/0", got) } func TestSafeRedisTarget_EmptyHost(t *testing.T) { // 空 Host(未啟用 Redis):仍回固定格式,不 panic。 got := SafeRedisTarget(config.RedisConfig{}) assert.Equal(t, ":6379/0", got) } // --------------------------------------------------------------------------- // NewRedisClient — happy path(miniredis) // --------------------------------------------------------------------------- func TestNewRedisClient_ConnectsAndPings(t *testing.T) { cfg := miniredisConfig(t) rc, err := NewRedisClient(context.Background(), cfg, nil) require.NoError(t, err, "miniredis 可達時應成功建連並 ping 通過") require.NotNil(t, rc) t.Cleanup(rc.Close) // 底層 client 可取得且可用 require.NotNil(t, rc.Client(), "Client() 應回傳非 nil 底層 client") // Ping 應通過 require.NoError(t, rc.Ping(context.Background()), "已連線的 client Ping 應成功") } func TestNewRedisClient_DefaultsPortWhenZero(t *testing.T) { // Port=0 路徑:NewRedisClient 應回退 6379。 // 這裡無法用 miniredis(它是隨機 port),改用「連不上的 host」驗證 // fail-fast 與 SafeRedisTarget 中的預設 port 一致呈現。 cfg := config.RedisConfig{ Host: "192.0.2.1", // TEST-NET-1,保證不可路由 Port: 0, ConnTimeout: 300 * time.Millisecond, } _, err := NewRedisClient(context.Background(), cfg, nil) require.Error(t, err, "連不上應 fail-fast") assert.Contains(t, err.Error(), "192.0.2.1:6379/0", "error 應含 SafeRedisTarget(含預設 port 6379)") } // --------------------------------------------------------------------------- // NewRedisClient — error path(fail-fast,不靜默 fallback) // --------------------------------------------------------------------------- func TestNewRedisClient_UnreachableHostFailsFast(t *testing.T) { cfg := config.RedisConfig{ Host: "192.0.2.1", // TEST-NET-1(RFC 5737),保證 dial 超時 Port: 6379, ConnTimeout: 300 * time.Millisecond, // 短逾時讓測試快速失敗 } start := time.Now() rc, err := NewRedisClient(context.Background(), cfg, nil) elapsed := time.Since(start) require.Error(t, err, "Redis 連不上時 NewRedisClient 必須回 error(fail-fast)") assert.Nil(t, rc, "失敗時不應回傳半成品 client") assert.Contains(t, err.Error(), "redis ping failed", "error 應標明 ping 失敗") // fail-fast:不應 hang 太久(給予 ConnTimeout 數倍寬容,避免 CI 抖動 flaky) assert.Less(t, elapsed, 5*time.Second, "fail-fast 不應 hang,實際耗時 %v", elapsed) } func TestNewRedisClient_ContextCancelledFailsFast(t *testing.T) { cfg := config.RedisConfig{ Host: "192.0.2.1", Port: 6379, ConnTimeout: 5 * time.Second, } // 已取消的 context:ping 應立即失敗,不等滿 ConnTimeout。 ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() _, err := NewRedisClient(ctx, cfg, nil) elapsed := time.Since(start) require.Error(t, err, "已取消的 ctx 下 NewRedisClient 應回 error") assert.Less(t, elapsed, 2*time.Second, "已取消 ctx 不應等滿 ConnTimeout,實際 %v", elapsed) } // --------------------------------------------------------------------------- // Close — graceful + 重複呼叫安全 + nil receiver // --------------------------------------------------------------------------- func TestRedisClient_Close_Idempotent(t *testing.T) { cfg := miniredisConfig(t) rc, err := NewRedisClient(context.Background(), cfg, nil) require.NoError(t, err) // 重複呼叫 Close 不應 panic(doc 承諾「重複呼叫安全」)。 assert.NotPanics(t, func() { rc.Close() rc.Close() }) } func TestRedisClient_Close_NilReceiverSafe(t *testing.T) { // 邊界值:nil receiver Close 不應 panic(防呆,main.go 失敗路徑可能傳 nil)。 var rc *RedisClient assert.NotPanics(t, func() { rc.Close() }) } // --------------------------------------------------------------------------- // Ping — 已關閉 client 的行為 // --------------------------------------------------------------------------- func TestRedisClient_Ping_AfterCloseReturnsError(t *testing.T) { cfg := miniredisConfig(t) rc, err := NewRedisClient(context.Background(), cfg, nil) require.NoError(t, err) rc.Close() // Close 後底層 client 已關,Ping 應回 error(go-redis: client is closed)。 err = rc.Ping(context.Background()) assert.Error(t, err, "已關閉的 client Ping 應回 error") }