Files
sundynix-pets/pets-be/internal/httpapi/admin_test.go
T
Blizzard 6c37785ce1
ci-cd / verify (push) Successful in 1m40s
ci-cd / deploy (push) Skipped
Complete admin dashboard with paginated user pet trees and walk relations
2026-10-04 11:45:15 +08:00

232 lines
7.0 KiB
Go

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)
}
}