package httpapi import ( "bytes" "encoding/json" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/sundynix/pets-be/internal/breeds" "github.com/sundynix/pets-be/internal/config" "github.com/sundynix/pets-be/internal/database" "github.com/sundynix/pets-be/internal/model" "gorm.io/gorm" "net/http/httptest" "os" "testing" "time" ) func featureDB(t *testing.T) (*gorm.DB, *config.Config) { t.Helper() if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" { t.Skip("PostgreSQL integration disabled") } cfg, err := config.Load() if err != nil { t.Fatal(err) } db, err := database.Open(cfg.Postgres, true) if err != nil { t.Fatal(err) } sqlDB, _ := db.DB() t.Cleanup(func() { sqlDB.Close() }) tx := db.Begin() if tx.Error != nil { t.Fatal(tx.Error) } t.Cleanup(func() { tx.Rollback() }) return tx, cfg } func featureCall(handler gin.HandlerFunc, method, path string, body any, actorID, id string) *httptest.ResponseRecorder { raw, _ := json.Marshal(body) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(method, path, bytes.NewReader(raw)) c.Request.Header.Set("Content-Type", "application/json") c.Set("actor_id", actorID) c.Params = gin.Params{{Key: "id", Value: id}} handler(c) return rec } func pairFrom(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() if rec.Code != 200 && rec.Code != 201 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var result struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { t.Fatal(err) } return result.Data } func TestAccessTokenTwoHours(t *testing.T) { api := New(nil, nil, &config.Config{JWT: config.JWTConfig{Secret: "test", ExpireHours: 168}}) raw, err := api.token(uuid.NewString(), "admin") if err != nil { t.Fatal(err) } c := &claims{} _, err = jwt.ParseWithClaims(raw, c, func(*jwt.Token) (any, error) { return []byte("test"), nil }) if err != nil { t.Fatal(err) } if c.ExpiresAt.Sub(c.IssuedAt.Time) != 2*time.Hour { t.Fatal("access token lifetime is not two hours") } } func TestRefreshRotationPostgres(t *testing.T) { tx, cfg := featureDB(t) a := New(tx, nil, cfg) user := model.User{OpenID: "refresh-test:" + uuid.NewString(), Nickname: "测试家长"} if err := tx.Create(&user).Error; err != nil { t.Fatal(err) } pair, err := a.newSession(user.ID, "user") if err != nil { t.Fatal(err) } old := pair["refresh_token"].(string) var session model.RefreshSession if err := tx.Where("token_hash = ?", refreshHash(old)).First(&session).Error; err != nil { t.Fatal(err) } if session.TokenHash == old { t.Fatal("raw refresh stored") } call := func(raw, role string) *httptest.ResponseRecorder { return featureCall(a.refreshToken(role), "POST", "/refresh", gin.H{"refresh_token": raw}, "", "") } if rec := call(old, "admin"); rec.Code != 401 { t.Fatal("user credential accepted for admin") } next := pairFrom(t, call(old, "user")) fresh := next["refresh_token"].(string) if fresh == old || next["refresh_expires_at"].(float64) < float64(time.Now().Add(2*time.Hour-time.Second).Unix()) { t.Fatal("rotation or two-hour expiry wrong") } if rec := call(old, "user"); rec.Code != 401 { t.Fatal("old credential reusable") } revoked := featureCall(a.revokeRefresh("user"), "POST", "/logout", gin.H{"refresh_token": fresh}, "", "") if revoked.Code != 200 { t.Fatal(revoked.Body.String()) } if rec := call(fresh, "user"); rec.Code != 401 { t.Fatal("logout failed to revoke") } // Logout invalidates the access token tied to the same session too. r := gin.New() r.GET("/protected", a.requireRole("user"), func(c *gin.Context) { ok(c, true) }) req := httptest.NewRequest("GET", "/protected", nil) req.Header.Set("Authorization", "Bearer "+next["token"].(string)) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != 401 { t.Fatal("revoked session access accepted") } pair, err = a.newSession(user.ID, "user") if err != nil { t.Fatal(err) } fresh = pair["refresh_token"].(string) if err := tx.Model(&user).Update("disabled", true).Error; err != nil { t.Fatal(err) } if rec := call(fresh, "user"); rec.Code != 401 { t.Fatal("disabled user refreshed") } if err := tx.Model(&user).Update("disabled", false).Error; err != nil { t.Fatal(err) } tx.Model(&model.RefreshSession{}).Where("token_hash = ?", refreshHash(fresh)).Update("expires_at", time.Now().Add(-time.Minute)) if rec := call(fresh, "user"); rec.Code != 401 { t.Fatal("expired credential refreshed") } admin := model.Admin{Username: "refresh-test:" + uuid.NewString(), PasswordHash: "fixture"} if err := tx.Create(&admin).Error; err != nil { t.Fatal(err) } pair, err = a.newSession(admin.ID, "admin") if err != nil { t.Fatal(err) } pairFrom(t, call(pair["refresh_token"].(string), "admin")) } func TestBreedCatalogPostgres(t *testing.T) { tx, cfg := featureDB(t) a := New(tx, nil, cfg) if added, err := breeds.Seed(tx); err != nil || added != 0 { t.Fatalf("idempotent seed: %d %v", added, err) } var source model.Breed if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&source).Error; err != nil { t.Fatal(err) } tx.Model(&source).Updates(map[string]any{"enabled": false, "name": "改名柯基测试"}) if added, err := breeds.Seed(tx); err != nil || added != 0 { t.Fatalf("seed restored renamed record: %d %v", added, err) } tx.First(&source, "id = ?", source.ID) if source.Enabled || source.Name != "改名柯基测试" { t.Fatal("seed overwrote edits") } rec := featureCall(a.listBreeds, "GET", "/breeds?q=bianmu", nil, "", "") if rec.Code != 200 { t.Fatal(rec.Body.String()) } // Short initials and Chinese aliases must both work. for _, q := range []string{"边牧", "bjm", "bianjing"} { rec = featureCall(a.listBreeds, "GET", "/breeds?q="+q, nil, "", "") if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("边境牧羊犬")) { t.Fatalf("search %s: %s", q, rec.Body) } } name := "测试品种" + uuid.NewString() body := gin.H{"name": name, "aliases": "testbreed", "enabled": false} created := pairFrom(t, featureCall(a.createBreed, "POST", "/breeds", body, "", "")) id := created["id"].(string) if created["enabled"].(bool) { t.Fatal("disabled creation ignored") } pairFrom(t, featureCall(a.setBreedEnabled, "PUT", "/breeds", gin.H{"enabled": true}, "", id)) body["enabled"] = true body["name"] = name + "更新" pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds", body, "", id)) // Duplicate request runs in a savepoint because PostgreSQL aborts a transaction on unique violations. tx.SavePoint("duplicate_breed") rec = featureCall(a.createBreed, "POST", "/breeds", body, "", "") if rec.Code != 409 { t.Fatalf("duplicate status %d: %s", rec.Code, rec.Body) } tx.RollbackTo("duplicate_breed") user := model.User{OpenID: "breed-test:" + uuid.NewString(), Nickname: "测试家长"} tx.Create(&user) selected, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "forged", BreedID: &id}) if !valid || selected.Breed != body["name"] { t.Fatal("selected name not resolved by ID") } custom, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "未收录地方犬"}) if !valid || custom.BreedID != nil || custom.Breed != "未收录地方犬" { t.Fatal("manual breed unavailable") } selected.UserID = user.ID tx.Create(&selected) tx.Model(&model.Breed{}).Where("id = ?", id).Update("enabled", false) _, valid = a.buildPet(testPetContext(user.ID, ""), petInput{Name: "新宠物", BreedID: &id}) if valid { t.Fatal("disabled breed newly selected") } _, valid = a.buildPet(testPetContext(user.ID, selected.ID), petInput{Name: "旧宠物", BreedID: &id}) if !valid { t.Fatal("existing disabled breed cannot be retained") } } func testPetContext(actorID, id string) *gin.Context { c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("actor_id", actorID) c.Params = gin.Params{{Key: "id", Value: id}} return c } func TestBreedSourcesAndEditorialProtection(t *testing.T) { tx, cfg := featureDB(t) a := New(tx, nil, cfg) var breed model.Breed if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&breed).Error; err != nil { t.Fatal(err) } detail := pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID)) if len(detail["references"].([]any)) != 4 { t.Fatal("Corgi references missing") } if detail["field_sources"].(map[string]any)["exercise"] != "rkc" { t.Fatal("exercise attribution wrong") } body := gin.H{"name": breed.Name, "english_name": breed.EnglishName, "enabled": true, "exercise": "编辑安排的散步活动", "summary": "新的中文介绍", "traits": gin.H{"运动活力": 2}} pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID)) if _, err := breeds.Seed(tx); err != nil { t.Fatal(err) } detail = pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID)) if detail["exercise"] != "编辑安排的散步活动" || detail["summary"] != "新的中文介绍" || detail["height"] != "" { t.Fatal("import overwrote manual edits or cleared fields") } if detail["field_sources"].(map[string]any)["exercise"] != "editorial" || len(detail["references"].([]any)) != 4 { t.Fatal("edited provenance or reference uniqueness lost") } body["traits"] = gin.H{"运动活力": 6} if rec := featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID); rec.Code != 400 { t.Fatalf("invalid rating accepted: %d", rec.Code) } tx.Model(&model.Breed{}).Where("id = ?", breed.ID).Update("enabled", false) if rec := featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID); rec.Code != 404 { t.Fatal("disabled encyclopedia visible") } }