jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

188 lines
6.9 KiB
Go
Raw Permalink 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.

// redis_test.go — RedisClient 的本機可跑測試(不需 docker
//
// owner: testing agentinternal/db redis 補測)
//
// 分類:
// - SafeRedisTarget純函式 unitlog 安全字串、不含密碼)
// - NewRedisClient / Ping / Close / Client用 in-process miniredis純 Go不需 docker
// 驗證連線建立、啟動 ping、graceful close、底層 client 取得。
// - fail-fast連不上的 host 應立即回 errorNewRedisClient 不靜默 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 — 純函式 unithappy / 預設值 / 不含密碼)
// ---------------------------------------------------------------------------
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 pathminiredis
// ---------------------------------------------------------------------------
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 pathfail-fast不靜默 fallback
// ---------------------------------------------------------------------------
func TestNewRedisClient_UnreachableHostFailsFast(t *testing.T) {
cfg := config.RedisConfig{
Host: "192.0.2.1", // TEST-NET-1RFC 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 必須回 errorfail-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,
}
// 已取消的 contextping 應立即失敗,不等滿 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 不應 panicdoc 承諾「重複呼叫安全」)。
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 應回 errorgo-redis: client is closed
err = rc.Ping(context.Background())
assert.Error(t, err, "已關閉的 client Ping 應回 error")
}