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>
186 lines
6.5 KiB
Go
186 lines
6.5 KiB
Go
//go:build dbtest
|
||
|
||
// DB 接入塊 4(4.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.go(miniredis,預設可跑)互補: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 在真 Redis(RESP 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。
|
||
//
|
||
// 用很短的 idle(2s)讓測試在合理時間內完成(不需 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 TTL(2s)+ 緩衝。
|
||
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 → 即使一直 Update,3s 後仍應消失。
|
||
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)
|
||
}
|
||
}
|