DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
6.9 KiB
Go
188 lines
6.9 KiB
Go
// 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")
|
||
}
|