visionA/visionA-backend/cmd/api-server/fixture_dbon_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

212 lines
9.1 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
// fixture_dbon_test.go — 塊 6 完整版6.5 e2e 回歸setupFixture 的 DB-on 變體。
//
// 既有 integration / e2eintegration_test.go / b5_integration_test.go / e2e_full_flow_test.go /
// oidc_e2e_test.go / conversion_e2e_test.go全部走 setupFixtureWithMaxUpload 的「6 個 in-memory
// store」版本——那是 local-dev fallback 行為。DB 接入塊 05 把 6 個 store 都接上 Postgres / Redis
// 後,必須證明「同一套 handler、改注入 PG/Redis storee2e 仍跑得通」,這就是本檔提供的
// setupFixtureDBOn。
//
// build tag `dbtest`:本檔與 e2e_dbon_test.go 只在 `-tags=dbtest` 時編譯(需要 Docker daemon
// 本機無 docker → 由 Orchestrator 在 130 docker host 補跑)。預設 `go test ./...` 不觸碰,
// 不影響既有 in-memory 模式的 e2e兩種模式並存
//
// 注入對照(對齊 cmd/api-server/main.go 的 dbPool != nil / redisClient != nil 分支):
//
// store in-memory既有 fixture DB-on本檔
// ----------------- --------------------------------------- -------------------------------------------
// PairingStore auth.NewInMemoryPairingStore() auth.NewPostgresPairingStore(pool)
// SessionTokenStore auth.NewInMemorySessionTokenStore() auth.NewPostgresSessionTokenStore(pool)
// DeviceRepo device.NewInMemoryRepository() device.NewPostgresRepository(pool)
// ModelRepo model.NewInMemoryRepository() model.NewPostgresRepository(pool)
// DeviceUnpairer api.NewInMemoryDeviceUnpairer(...) api.NewPostgresDeviceUnpairer(pool,...)
// userSession usersession.NewInMemoryStore() usersession.NewRedisUserSessionStore(miniredis)
// tunnel session session.NewInMemoryStore() session.NewInMemoryStore()(不持久化、不受 DB 影響)
//
// 為什麼 userSession 用 miniredis 而非真 Redisminiredis 在 process 內跑、無 Docker / 無網路需求,
// 與真 RedisUserSessionStore 走同一條 go-redis client 介面main.go 也是注入 go-redis client
// 真 Redis130行為已由 internal/usersession/redis_integration_test.go 另行覆蓋;此處 e2e 聚焦
// 「PG 持久化 + Redis session 都接上後整鏈跑通」miniredis 足夠且更乾淨。
package main
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
goredis "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/converter"
"visiona-backend/internal/db"
"visiona-backend/internal/db/testsupport"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/oidctest"
"visiona-backend/internal/relay"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/usersession"
)
// dbOnFixture 是 setupFixtureDBOn 的回傳:在 testFixture 之上多帶 DB-on 專屬的引用,
// 讓「重啟資料還在」「直接查 DB 斷言 cascade」這類 test 能拿到 pool / cfg。
type dbOnFixture struct {
*testFixture
// tdb 是 testcontainers 起的一次性 Postgres含已 migrate 的 schema
// 容器 / pool 的 teardown 由 SetupTestDB 的 t.Cleanup 管,呼叫端不需手動關。
tdb *testsupport.TestDB
// pool 是本次 fixture wire 進所有 PG store 的連線池(= tdb.Pool
// 重啟模擬時,會新建第二個 pool 指向同一個容器(同 tdb.Cfg來證明資料落在 DB 不在 process 記憶體。
pool *db.Pool
// miniRedis 是 user session 用的 in-process Redis。
miniRedis *miniredis.Miniredis
}
// ensureUser 為一個 OIDC sub必須是合法 UUIDupsert 一筆 users 列。
//
// 為什麼需要DB-on 模式下 models.owner_user_id / devices.owner_user_id / *_tokens.user_id 都是
// `UUID NOT NULL REFERENCES users(id)`。AuthenticatedClient 的 sub 是任意字串、且 production 的
// OIDC callback 不會 auto-provision users 列(見回報給 Orchestrator 的疑似 production bug
// 因此 DB-on e2e 在「登入後打需要 owner 的 API」前必須先確保對應 users 列存在,否則 FK 失敗。
//
// 回傳傳入的 sub方便 inline 使用)。
func (f *dbOnFixture) ensureUser(t *testing.T, sub, email string) string {
t.Helper()
_, err := f.pool.Pool().Exec(context.Background(),
`INSERT INTO users (id, email) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
sub, email)
require.NoError(t, err, "ensure users row for OIDC sub %s", sub)
return sub
}
// setupFixtureDBOn 啟動與 setupFixtureWithMaxUpload 相同的 5 段架構,但 6 個持久化 store
// 改注入 Postgrestestcontainers+ Redisminiredis
//
// 與 in-memory 版的唯一差別在 store 注入handler / router / OIDC / tunnel 完全相同,
// 證明「介面不變、只換後端」的塊 05 設計目標在 e2e 層成立。
func setupFixtureDBOn(t *testing.T, localHandler http.Handler) *dbOnFixture {
t.Helper()
// 0. 起 Postgres 容器(已 migrate+ in-process Redis。
tdb := testsupport.SetupTestDB(t)
mr := miniredis.RunT(t)
// 1. fake local-tool
localBackend := httptest.NewServer(localHandler)
// 2. remote-proxytunnel session store 仍 in-memoryyamux 連線註冊表,本來就不落 DB
tunnelStore := session.NewInMemoryStore()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
relaySrv := relay.NewServer(tunnelStore, logger, relay.Options{KeepAliveInterval: 500 * time.Millisecond})
internalSrv := relay.NewInternalServer(tunnelStore, logger)
tunnelMux := http.NewServeMux()
tunnelMux.HandleFunc("/tunnel/connect", relaySrv.HandleTunnelConnect)
tunnelTS := httptest.NewServer(tunnelMux)
internalMux := http.NewServeMux()
internalSrv.Routes(internalMux)
internalTS := httptest.NewServer(internalMux)
// 3. api-server proxy/forwarder指向 internalTS
proxyClient := session.NewHTTPProxyClient(internalTS.URL, logger)
forwarder := session.NewForwarder(internalTS.URL, logger)
sessionStore := session.NewProxyClientStore(proxyClient, forwarder)
storeDir := t.TempDir()
lazy := &lazyHandler{}
apiTS := httptest.NewServer(lazy)
storeStore, err := storage.NewLocalFSStore(storeDir, apiTS.URL+"/storage", "test-secret")
require.NoError(t, err)
// 4. fake OIDC + OIDC client同 in-memory 版)
fakeOIDC := oidctest.NewServer(t,
oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret),
)
callbackURL := apiTS.URL + "/api/auth/callback"
oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 5*time.Second)
oidcProvider, err := oidc.NewProvider(oidcCtx, oidc.ProviderConfig{
IssuerURL: fakeOIDC.URL,
ClientID: fakeOIDC.ClientID,
ClientSecret: fakeOIDC.ClientSecret,
RedirectURL: callbackURL,
})
oidcCancel()
require.NoError(t, err, "fixture(dbon): OIDC provider init failed")
// ===== DB-on store 注入(對齊 main.go dbPool/redisClient 非 nil 分支) =====
pool, err := db.NewPool(context.Background(), tdb.Cfg, logger)
require.NoError(t, err, "fixture(dbon): second pool for fixture")
t.Cleanup(pool.Close)
pgPairingStore := auth.NewPostgresPairingStore(pool.Pool())
pgSessionTokenStore := auth.NewPostgresSessionTokenStore(pool.Pool())
pgDeviceRepo := device.NewPostgresRepository(pool.Pool())
pgModelRepo := model.NewPostgresRepository(pool.Pool())
pgUnpairer := api.NewPostgresDeviceUnpairer(pool.Pool(), pgDeviceRepo, pgPairingStore, pgSessionTokenStore, logger)
// user sessionRedisminiredis。TTL 對齊 main.go 預設量級。
redisClient := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
userSessionStore := usersession.NewRedisUserSessionStore(redisClient, 24*time.Hour, 168*time.Hour)
sessionMgr := usersession.NewManager(userSessionStore, usersession.CookieConfig{
Name: "visiona_session",
Path: "/",
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
SigningKey: []byte(fixtureSessionSecret),
})
router := api.NewRouter(api.Deps{
Logger: logger,
PairingStore: pgPairingStore,
SessionTokenStore: pgSessionTokenStore,
SessionStore: sessionStore,
Forwarder: forwarder,
DeviceRepo: pgDeviceRepo,
ModelRepo: pgModelRepo,
DeviceUnpairer: pgUnpairer, // 塊 5.2 cascadePostgres tx
Storage: storeStore,
Converter: converter.NewStubClient(),
RelayPublicURL: tunnelTS.URL,
OIDCProvider: oidcProvider,
SessionManager: sessionMgr,
OIDCPostLoginURL: apiTS.URL,
})
lazy.Set(router)
tf := &testFixture{
apiServer: apiTS,
internalSrv: internalTS,
tunnelSrv: tunnelTS,
localBackend: localBackend,
store: tunnelStore,
forwarder: forwarder,
fakeOIDC: fakeOIDC,
pairingStore: nil, // DB-on 模式不暴露 in-memory pairingStore需要直接查時走 f.pool
sessionMgr: sessionMgr,
router: router,
}
return &dbOnFixture{
testFixture: tf,
tdb: tdb,
pool: pool,
miniRedis: mr,
}
}