//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, } }