visionA/visionA-backend/internal/usersession/redis_integration_test.go
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

186 lines
6.5 KiB
Go
Raw 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.

//go:build dbtest
// DB 接入塊 44.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.gominiredis預設可跑互補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"
// 含非 ASCII中文 + emoji + 特殊符號)的 Extra驗證 JSON 在真 RedisRESP binary
// 傳輸)下 UTF-8 round-trip 不被破壞。miniredis 不一定能暴露 binary 層問題,故在真 Redis 驗。
const cjkVal = "返回首頁/設定 ⚙️ — 名稱「測試」"
const cjkKey = "中文鍵"
sess.Extra = map[string]any{
"return_to": "/x",
"n": float64(7),
"zh": cjkVal,
cjkKey: "值🚀",
}
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)
}
// 非 ASCII round-trip值與「中文鍵」皆須完整保留含多位元組 UTF-8 與 emoji
if got.Extra["zh"] != cjkVal {
t.Fatalf("non-ASCII Extra value round-trip mismatch: got %q want %q", got.Extra["zh"], cjkVal)
}
if got.Extra[cjkKey] != "值🚀" {
t.Fatalf("non-ASCII Extra key round-trip mismatch: got %q (key=%q)", got.Extra[cjkKey], cjkKey)
}
// 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。
//
// 用很短的 idle2s讓測試在合理時間內完成不需 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 TTL2s+ 緩衝。
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 → 即使一直 Update3s 後仍應消失。
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)
}
}