Complete admin dashboard with paginated user pet trees and walk relations
ci-cd / verify (push) Successful in 1m40s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-04 11:45:15 +08:00
parent 3655a6a89e
commit 6c37785ce1
26 changed files with 3155 additions and 601 deletions
+396 -32
View File
@@ -1,98 +1,462 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
"net/http"
"gorm.io/gorm"
)
type adminPage struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
Items any `json:"items"`
}
func pageParams(c *gin.Context) (adminPage, bool) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
size, sizeErr := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if err != nil || sizeErr != nil || page < 1 || page > 1000000 || size < 1 || size > 100 || len([]rune(c.Query("q"))) > 128 {
fail(c, http.StatusBadRequest, "分页或搜索参数无效")
return adminPage{}, false
}
return adminPage{Page: page, PageSize: size}, true
}
func adminRows[T any](c *gin.Context, query *gorm.DB, order string) ([]T, adminPage, bool) {
page, valid := pageParams(c)
if !valid {
return nil, page, false
}
if err := query.Count(&page.Total).Error; err != nil {
fail(c, 500, "读取记录数量失败")
return nil, page, false
}
rows := make([]T, 0)
if err := query.Order(order).Offset((page.Page - 1) * page.PageSize).Limit(page.PageSize).Find(&rows).Error; err != nil {
fail(c, 500, "读取记录失败")
return nil, page, false
}
return rows, page, true
}
func search(query *gorm.DB, c *gin.Context, columns ...string) *gorm.DB {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
return query
}
// Only allow literal substring search. Columns are constants supplied by handlers.
value := "%" + strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(q) + "%"
clauses := make([]string, 0, len(columns))
args := make([]any, 0, len(columns))
for _, column := range columns {
clauses = append(clauses, column+" ILIKE ?")
args = append(args, value)
}
return query.Where("("+strings.Join(clauses, " OR ")+")", args...)
}
func filterStatus(c *gin.Context, query *gorm.DB, column string, allowed ...string) (*gorm.DB, bool) {
status := c.Query("status")
if status == "" {
return query, true
}
for _, value := range allowed {
if status == value {
return query.Where(column+" = ?", status), true
}
}
fail(c, 400, "筛选状态无效")
return query, false
}
func (a *API) userNames(ids []string) (map[string]string, error) {
names := map[string]string{}
if len(ids) == 0 {
return names, nil
}
var users []model.User
err := a.db.Select("id", "nickname").Where("id IN ?", ids).Find(&users).Error
for _, user := range users {
names[user.ID] = user.Nickname
}
return names, err
}
func (a *API) adminStats(c *gin.Context) {
counts := gin.H{}
for _, pair := range []struct {
name string
value any
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}} {
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} {
var n int64
a.db.Model(pair.value).Count(&n)
if err := a.db.Model(pair.value).Count(&n).Error; err != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, pair := range []struct {
name string
query *gorm.DB
}{
{"today_walks", a.db.Model(&model.Walk{}).Where("started_at >= ?", today)},
{"active_walks", a.db.Model(&model.Walk{}).Where("status IN ?", []string{"active", "paused"})},
{"upcoming_meetups", a.db.Model(&model.Meetup{}).Where("status = ? AND starts_at > ?", "open", now)},
{"today_completions", a.db.Model(&model.TaskCompletion{}).Where("date = ?", today)},
} {
var n int64
if pair.query.Count(&n).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n
}
var metrics struct {
Distance float64
Duration int64
Storage int64
}
if a.db.Model(&model.Walk{}).Select("COALESCE(SUM(distance_meters),0) AS distance, COALESCE(SUM(duration_seconds),0) AS duration").Scan(&metrics).Error != nil || a.db.Model(&model.File{}).Select("COALESCE(SUM(size),0) AS storage").Scan(&metrics).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts["distance_meters"] = metrics.Distance
counts["duration_seconds"] = metrics.Duration
counts["storage_bytes"] = metrics.Storage
type dayCount struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
var grouped []dayCount
since := today.AddDate(0, 0, -6)
if a.db.Model(&model.Walk{}).Select("to_char(started_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, COUNT(*) AS count").Where("started_at >= ?", since).Group("date").Scan(&grouped).Error != nil {
fail(c, 500, "读取趋势失败")
return
}
daily := make([]dayCount, 0, 7)
for day := since; !day.After(today); day = day.AddDate(0, 0, 1) {
entry := dayCount{Date: day.Format("2006-01-02")}
for _, row := range grouped {
if row.Date == entry.Date {
entry.Count = row.Count
}
}
daily = append(daily, entry)
}
counts["daily_walks"] = daily
ok(c, counts)
}
func (a *API) adminUsers(c *gin.Context) {
var rows []model.User
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取用户失败")
query := search(a.db.Model(&model.User{}), c, "nickname", "id::text")
switch c.Query("status") {
case "":
case "enabled":
query = query.Where("disabled = false")
case "disabled":
query = query.Where("disabled = true")
default:
fail(c, 400, "筛选状态无效")
return
}
rows, page, valid := adminRows[model.User](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, row := range rows {
ids = append(ids, row.ID)
}
var totals []struct {
UserID string
Count int64
}
if len(ids) > 0 && a.db.Model(&model.Pet{}).Select("user_id, COUNT(*) AS count").Where("user_id IN ?", ids).Group("user_id").Scan(&totals).Error != nil {
fail(c, 500, "读取宠物数量失败")
return
}
counts := map[string]int64{}
for _, row := range totals {
counts[row.UserID] = row.Count
}
items := make([]gin.H, 0, len(rows))
for _, u := range rows {
v := a.userView(u)
v["disabled"] = u.Disabled
items = append(items, v)
view := a.userView(u)
view["disabled"] = u.Disabled
view["pet_count"] = counts[u.ID]
items = append(items, view)
}
page.Items = items
ok(c, page)
}
func (a *API) adminUserPets(c *gin.Context) {
id := c.Param("id")
if !validID(id) {
fail(c, 400, "用户 ID 无效")
return
}
var user model.User
if err := a.db.First(&user, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在")
} else {
fail(c, 500, "读取用户失败")
}
return
}
var rows []model.Pet
if a.db.Where("user_id = ?", id).Order("archived ASC, created_at DESC, id DESC").Find(&rows).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, pet := range rows {
view := a.petView(pet)
view["archived"] = pet.Archived
view["user_id"] = id
view["user_name"] = user.Nickname
items = append(items, view)
}
ok(c, items)
}
func (a *API) adminPets(c *gin.Context) {
var rows []model.Pet
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取宠物失败")
query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text")
switch c.Query("status") {
case "":
case "active":
query = query.Where("archived = false")
case "archived":
query = query.Where("archived = true")
default:
fail(c, 400, "筛选状态无效")
return
}
rows, page, valid := adminRows[model.Pet](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, p := range rows {
ids = append(ids, p.UserID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取主人信息失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, p := range rows {
v := a.petView(p)
v["user_id"] = p.UserID
v["user_name"] = names[p.UserID]
v["archived"] = p.Archived
items = append(items, v)
}
ok(c, items)
page.Items = items
ok(c, page)
}
func (a *API) adminWalks(c *gin.Context) {
var rows []model.Walk
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取遛狗失败")
query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text")
query, valid := filterStatus(c, query, "status", "active", "paused", "finished")
if !valid {
return
}
ok(c, rows)
rows, page, valid := adminRows[model.Walk](c, query, "started_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
walkIDs := []string{}
for _, w := range rows {
ids = append(ids, w.UserID)
walkIDs = append(walkIDs, w.ID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取用户失败")
return
}
var links []struct {
WalkID string
PetName string
}
if len(walkIDs) > 0 && a.db.Model(&model.WalkPet{}).Select("sundynix_walk_pet.walk_id, p.name AS pet_name").Joins("JOIN sundynix_pet p ON p.id = sundynix_walk_pet.pet_id").Where("walk_id IN ?", walkIDs).Order("p.created_at ASC").Scan(&links).Error != nil {
fail(c, 500, "读取同行宠物失败")
return
}
pets := map[string][]string{}
for _, link := range links {
pets[link.WalkID] = append(pets[link.WalkID], link.PetName)
}
items := make([]gin.H, 0, len(rows))
for _, w := range rows {
items = append(items, gin.H{"id": w.ID, "user_id": w.UserID, "user_name": names[w.UserID], "pet_names": pets[w.ID], "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled})
}
page.Items = items
ok(c, page)
}
func (a *API) adminWalkDetail(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 400, "记录 ID 无效")
return
}
var walk model.Walk
if err := a.db.First(&walk, "id = ?", c.Param("id")).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "遛狗记录不存在")
} else {
fail(c, 500, "读取记录失败")
}
return
}
var points []model.WalkPoint
if a.db.Where("walk_id = ?", walk.ID).Order("recorded_at ASC, id ASC").Find(&points).Error != nil {
fail(c, 500, "读取足迹失败")
return
}
ok(c, gin.H{"id": walk.ID, "points": points})
}
func (a *API) adminMeetups(c *gin.Context) {
var rows []model.Meetup
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取约遛失败")
query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text")
query, valid := filterStatus(c, query, "status", "open", "cancelled")
if !valid {
return
}
rows, page, valid := adminRows[model.Meetup](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, m := range rows {
ids = append(ids, m.HostUserID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取发起人失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, m := range rows {
items = append(items, a.meetupView(m))
v := a.meetupView(m)
v["user_name"] = names[m.HostUserID]
items = append(items, v)
}
ok(c, items)
page.Items = items
ok(c, page)
}
func (a *API) adminTasks(c *gin.Context) {
query := search(a.db.Model(&model.CareTask{}), c, "title", "id::text", "user_id::text", "pet_id::text")
query, valid := filterStatus(c, query, "category", "food", "water", "walk", "medicine", "grooming", "other")
if !valid {
return
}
rows, page, valid := adminRows[model.CareTask](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
for _, t := range rows {
ids = append(ids, t.UserID)
petIDs = append(petIDs, t.PetID)
taskIDs = append(taskIDs, t.ID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取用户失败")
return
}
var pets []model.Pet
if len(petIDs) > 0 && a.db.Select("id", "name").Where("id IN ?", petIDs).Find(&pets).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
petNames := map[string]string{}
for _, p := range pets {
petNames[p.ID] = p.Name
}
var completions []model.TaskCompletion
if len(taskIDs) > 0 && a.db.Where("task_id IN ? AND date = ?", taskIDs, time.Now().Format("2006-01-02")).Find(&completions).Error != nil {
fail(c, 500, "读取任务完成情况失败")
return
}
done := map[string]bool{}
for _, completion := range completions {
done[completion.TaskID] = true
}
items := make([]gin.H, 0, len(rows))
for _, t := range rows {
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "pet_name": petNames[t.PetID], "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
}
page.Items = items
ok(c, page)
}
func (a *API) adminFiles(c *gin.Context) {
query := search(a.db.Model(&model.File{}), c, "name", "md5", "id::text")
rows, page, valid := adminRows[model.File](c, query, "created_at DESC, id DESC")
if !valid {
return
}
items := make([]gin.H, 0, len(rows))
for _, file := range rows {
v := a.fileView(file)
v["created_at"] = file.CreatedAt
v["bucket"] = file.Bucket
v["object_key"] = file.ObjectKey
items = append(items, v)
}
page.Items = items
ok(c, page)
}
func (a *API) adminSetUserDisabled(c *gin.Context) {
var in struct {
Disabled bool `json:"disabled"`
Disabled *bool `json:"disabled"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == nil {
fail(c, 400, "参数无效")
return
}
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled)
if r.Error != nil || r.RowsAffected == 0 {
err := a.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", *in.Disabled)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
// Disabling an account also revokes any publicly shared locations.
if *in.Disabled {
return tx.Model(&model.Walk{}).Where("user_id = ?", c.Param("id")).Updates(map[string]any{"share_enabled": false, "share_expires_at": nil}).Error
}
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在")
return
}
ok(c, gin.H{"disabled": in.Disabled})
if err != nil {
fail(c, 500, "更新用户失败")
return
}
ok(c, gin.H{"disabled": *in.Disabled})
}
func (a *API) adminSetMeetupStatus(c *gin.Context) {
var in struct {
Status string `json:"status"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") {
fail(c, http.StatusBadRequest, "状态无效")
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Status != "cancelled" {
fail(c, 400, "状态无效")
return
}
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status)
if r.Error != nil || r.RowsAffected == 0 {
fail(c, 404, "约遛不存在")
result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status)
if result.Error != nil {
fail(c, 500, "更新约遛失败")
return
}
if result.RowsAffected == 0 {
fail(c, 409, "约遛不存在或已取消")
return
}
ok(c, gin.H{"status": in.Status})
+231
View File
@@ -0,0 +1,231 @@
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)
}
}
+4
View File
@@ -73,9 +73,13 @@ func (a *API) Register(r *gin.Engine) {
admin.Use(a.requireRole("admin"))
admin.GET("/stats", a.adminStats)
admin.GET("/users", a.adminUsers)
admin.GET("/users/:id/pets", a.adminUserPets)
admin.GET("/pets", a.adminPets)
admin.GET("/walks", a.adminWalks)
admin.GET("/walks/:id", a.adminWalkDetail)
admin.GET("/meetups", a.adminMeetups)
admin.GET("/tasks", a.adminTasks)
admin.GET("/files", a.adminFiles)
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
}