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>
212 lines
9.1 KiB
Go
212 lines
9.1 KiB
Go
//go:build dbtest
|
||
|
||
// fixture_dbon_test.go — 塊 6 完整版(6.5 e2e 回歸):setupFixture 的 DB-on 變體。
|
||
//
|
||
// 既有 integration / e2e(integration_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 接入塊 0–5 把 6 個 store 都接上 Postgres / Redis
|
||
// 後,必須證明「同一套 handler、改注入 PG/Redis store,e2e 仍跑得通」,這就是本檔提供的
|
||
// 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 而非真 Redis:miniredis 在 process 內跑、無 Docker / 無網路需求,
|
||
// 與真 RedisUserSessionStore 走同一條 go-redis client 介面(main.go 也是注入 go-redis client)。
|
||
// 真 Redis(130)行為已由 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(必須是合法 UUID)upsert 一筆 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
|
||
// 改注入 Postgres(testcontainers)+ Redis(miniredis)。
|
||
//
|
||
// 與 in-memory 版的唯一差別在 store 注入;handler / router / OIDC / tunnel 完全相同,
|
||
// 證明「介面不變、只換後端」的塊 0–5 設計目標在 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-proxy(tunnel session store 仍 in-memory:yamux 連線註冊表,本來就不落 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 session:Redis(miniredis)。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 cascade(Postgres 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,
|
||
}
|
||
}
|