package httpapi import ( "encoding/json" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/sundynix/pets-be/internal/config" "github.com/sundynix/pets-be/internal/database" "github.com/sundynix/pets-be/internal/model" ) func TestAdminPageValidation(t *testing.T) { gin.SetMode(gin.TestMode) for _, query := range []string{"", "?page=2&page_size=10", "?page=0", "?page_size=101", "?page=abc", "?page=-1", "?page_size=0"} { recorder := httptest.NewRecorder() context, _ := gin.CreateTestContext(recorder) context.Request = httptest.NewRequest(http.MethodGet, "/api/admin/users"+query, nil) page, valid := pageParams(context) expected := query == "" || query == "?page=2&page_size=10" if valid != expected { t.Errorf("%s: valid=%v", query, valid) } if query == "" && (page.Page != 1 || page.PageSize != 10) { t.Fatalf("default pagination: %+v", page) } if !valid && recorder.Code != 400 { t.Errorf("%s: status=%d", query, recorder.Code) } } } func TestAdminListsRequireAdminRole(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{} cfg.JWT.Secret = "test-secret" api := New(nil, nil, cfg) router := gin.New() api.Register(router) token, err := api.token(uuid.NewString(), "user") if err != nil { t.Fatal(err) } for _, route := range []string{"users", "users/" + uuid.NewString() + "/pets", "pets", "walks", "walks/" + uuid.NewString(), "meetups", "tasks", "files", "stats"} { for _, auth := range []bool{false, true} { request := httptest.NewRequest(http.MethodGet, "/api/admin/"+route, nil) if auth { request.Header.Set("Authorization", "Bearer "+token) } recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) expected := http.StatusUnauthorized if auth { expected = http.StatusForbidden } if recorder.Code != expected { t.Errorf("%s auth=%v: status=%d", route, auth, recorder.Code) } } } } // Opt-in read-only verification against the configured PostgreSQL; never seeds or changes data. func TestAdminPostgresReadOnly(t *testing.T) { if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" { t.Skip("set PETS_ADMIN_INTEGRATION=1 for PostgreSQL verification") } 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() defer sqlDB.Close() gin.SetMode(gin.TestMode) api := New(db, nil, cfg) router := gin.New() api.Register(router) token, err := api.token(uuid.NewString(), "admin") if err != nil { t.Fatal(err) } read := func(path string) json.RawMessage { t.Helper() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != 200 { t.Fatalf("GET %s: %d %s", path, recorder.Code, recorder.Body.String()) } var body struct { Data json.RawMessage `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { t.Fatal(err) } return body.Data } read("/api/admin/stats") for _, section := range []string{"users", "pets", "walks", "meetups", "tasks", "files"} { var page struct { Items []struct { ID string `json:"id"` UserID string `json:"user_id"` PetNames []string `json:"pet_names"` } `json:"items"` Total int64 `json:"total"` PageSize int `json:"page_size"` Page int `json:"page"` } if err := json.Unmarshal(read("/api/admin/"+section+"?page=1&page_size=10"), &page); err != nil { t.Fatal(err) } if page.Page != 1 || page.PageSize != 10 || len(page.Items) > 10 || page.Total < int64(len(page.Items)) { t.Fatalf("invalid %s pagination: %+v", section, page) } read("/api/admin/" + section + "?page=2&page_size=10") read("/api/admin/" + section + "?q=%25%27%5F") if len(page.Items) > 0 && section == "users" { read("/api/admin/users/" + page.Items[0].ID + "/pets") } if len(page.Items) > 0 && section == "walks" { read("/api/admin/walks/" + page.Items[0].ID) } } } // Fixtures live inside a transaction and are always rolled back. func TestAdminPaginationAndWalkRelations(t *testing.T) { gin.SetMode(gin.TestMode) 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() defer sqlDB.Close() tx := db.Begin() if tx.Error != nil { t.Fatal(tx.Error) } defer tx.Rollback() prefix := "admin-test-" + uuid.NewString() users := make([]model.User, 11) for i := range users { users[i] = model.User{Nickname: prefix, OpenID: uuid.NewString()} } if tx.Create(&users).Error != nil { t.Fatal("create temporary users") } pets := []model.Pet{{UserID: users[0].ID, Name: "奶盖", Species: "dog"}, {UserID: users[0].ID, Name: "布丁", Species: "dog"}} if tx.Create(&pets).Error != nil { t.Fatal("create temporary pets") } now := time.Now() walk := model.Walk{UserID: users[0].ID, Status: "finished", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()} if err := tx.Create(&walk).Error; err != nil { t.Fatal(err) } for _, pet := range pets { if err := tx.Create(&model.WalkPet{WalkID: walk.ID, PetID: pet.ID}).Error; err != nil { t.Fatal(err) } } api := New(tx, nil, cfg) router := gin.New() api.Register(router) token, _ := api.token(uuid.NewString(), "admin") read := func(path string) json.RawMessage { t.Helper() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != 200 { t.Fatalf("%s: %d %s", path, recorder.Code, recorder.Body.String()) } var body struct { Data json.RawMessage `json:"data"` } json.Unmarshal(recorder.Body.Bytes(), &body) return body.Data } for _, sample := range []struct { page string size int }{{"1", 10}, {"2", 1}} { var result struct { Items []json.RawMessage `json:"items"` Total int64 `json:"total"` } if err := json.Unmarshal(read("/api/admin/users?q="+prefix+"&page="+sample.page), &result); err != nil { t.Fatal(err) } if result.Total != 11 || len(result.Items) != sample.size { t.Fatalf("pagination got %d rows / %d total", len(result.Items), result.Total) } } var children []struct { Name string `json:"name"` } json.Unmarshal(read("/api/admin/users/"+users[0].ID+"/pets"), &children) if len(children) != 2 { t.Fatalf("user tree lost pets: %+v", children) } var result struct { Items []struct { UserID string `json:"user_id"` UserName string `json:"user_name"` PetNames []string `json:"pet_names"` } `json:"items"` } json.Unmarshal(read("/api/admin/walks?q="+walk.ID), &result) if len(result.Items) != 1 || result.Items[0].UserID != users[0].ID || result.Items[0].UserName != prefix || len(result.Items[0].PetNames) != 2 { t.Fatalf("walk relation incomplete: %+v", result) } }