// dbon_fk_fix_test.go — DB-on FK 收尾兩個問題的 integration 驗證(in-memory wiring)。 // // 不帶 build tag:屬於預設 `go test ./...` 範圍。用 fixture 的 in-memory store + in-memory // exchanger 走真實 OIDC login flow / pairing exchange flow,驗證: // - 問題 #1:OIDC callback 成功後 user 被 provision 進 UserStore。 // - 問題 #2:pairing exchange 成功後自建一筆 device,且該 device owner = 登入 user。 // // DB-on 行為(真 FK / 交易)由 internal/user/postgres_store_db_test.go 與 // internal/api/pairing_exchange_db_test.go 的 dbtest 覆蓋(待 130 補跑)。 package main import ( "bytes" "context" "encoding/json" "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/api" "visiona-backend/internal/auth" ) // TestDBOnFix_OIDCCallbackProvisionsUser 驗證問題 #1:OIDC callback 成功後 user 進 UserStore。 func TestDBOnFix_OIDCCallbackProvisionsUser(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer f.Close() // 登入前:user 不存在 _, err := f.userStore.Get(context.Background(), "alice-sub") require.Error(t, err, "登入前 user 不應存在") // 走完整 OIDC login flow(callback 會 Upsert user) _ = f.AuthenticatedClient(t, "alice-sub", "alice@example.com") // 登入後:user 已被 provision got, err := f.userStore.Get(context.Background(), "alice-sub") require.NoError(t, err, "OIDC callback 後 user 應被 provision 進 UserStore") assert.Equal(t, "alice-sub", got.ID) assert.Equal(t, "alice@example.com", got.Email) } // TestDBOnFix_ExchangeProvisionsDevice 驗證問題 #2 + A' 重塑(WP-B B3/B4):pairing exchange // 後建 agent 的 representative device 供 session token 綁定。 // // A' 語意變更:exchange 無 USB 上報時只建 representative device(is_representative=true), // 而 List(B4)會 filter 掉 representative(只列真 USB)——故 exchange 前後 List 都為空。 // 「device 有被建」改由「representative device 存在且綁到 session」驗證。 func TestDBOnFix_ExchangeProvisionsDevice(t *testing.T) { f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer f.Close() const sub = "bob-sub" client := f.AuthenticatedClient(t, sub, "bob@example.com") // 登入後但 exchange 前:該 user 名下無(真 USB)device before, err := f.deviceRepo.List(context.Background(), sub) require.NoError(t, err) require.Empty(t, before, "exchange 前不應有 device") // 1) 產 pairing token tokResp, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil) require.NoError(t, err) defer tokResp.Body.Close() require.Equal(t, http.StatusOK, tokResp.StatusCode) var tokBody map[string]any require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody)) pairingTok := tokBody["data"].(map[string]any)["token"].(string) // 2) exchange(不走 AuthMiddleware)→ 建 agent + representative device + session token reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok}) exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", "application/json", bytes.NewReader(reqBody)) require.NoError(t, err) defer exchResp.Body.Close() require.Equal(t, http.StatusOK, exchResp.StatusCode) var exchBody map[string]any require.NoError(t, json.NewDecoder(exchResp.Body).Decode(&exchBody)) sessionTok := exchBody["data"].(map[string]any)["session_token"].(string) require.True(t, auth.IsValidSessionToken(sessionTok)) // 3) exchange 後:List(真 USB)仍為空——A' 下無 USB 上報只建 representative(B4 filter)。 after, err := f.deviceRepo.List(context.Background(), sub) require.NoError(t, err) require.Empty(t, after, "無 USB 上報 → List(真 USB)仍為空(representative 被 filter)") // 4) session token 綁到一顆存在的 representative device(is_representative=true、owner 對齊)。 tok, err := f.sessionTokenStore.Get(context.Background(), sessionTok) require.NoError(t, err) require.NotEmpty(t, tok.DeviceID, "session token 應綁一個 device") rep, err := f.deviceRepo.Get(context.Background(), tok.DeviceID) require.NoError(t, err, "session 綁的 representative device 應存在") assert.True(t, rep.IsRepresentative, "exchange 建的應為 representative device") assert.Equal(t, sub, rep.OwnerUserID, "device owner 應為登入 user") assert.NotNil(t, rep.PairedAt, "representative device 應設 paired_at") }