Files
kefu/im/backend/internal/app/pets_integration_test.go
T
Your NameandClaude Opus 5 aee1881e69 宠物板块第一期:宠物档案与后台审核
方案第一期的后端部分。宠物档案是整个板块的根——代喂任务、送养帖、
配种信息都会挂在它上面,所以免疫、绝育、体重这些事实只在这里存一份,
不在各业务表里重复填写与互相矛盾。

- pets / pet_media 两张表(迁移 036),另加三个可在管理端调整的配置:
  总开关、单账号档案上限、单只宠物照片上限。
- 客户端接口:档案增删改查。日期在校验阶段解析,格式或"未来的免疫日期"
  这类问题返回 400 并说明是哪个字段,而不是从写库深处抛出 500。
- 软删除:档案从列表消失但记录保留,将来代喂订单与送养记录仍能追溯。
- 后台审核:列表带主人昵称与星遇号;驳回必须填原因;被驳回的档案对外
  不可见,但主人仍能看到并读到原因。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:53:55 +08:00

216 lines
8.4 KiB
Go

package app
import (
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The profile is the root every other pet feature hangs off, so what it accepts
// decides what a feeding task or an adoption listing can rely on later.
func TestPetProfileMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
create := func(user int64, body string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
a.createPet(w, imTestRequest(user, "POST", "/api/v1/pets", body))
return w.Code, w.Body.String()
}
list := func(user int64) []petView {
t.Helper()
var payload struct {
Items []petView `json:"items"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(imTestCall(t, a.myPets, user, "GET", "/api/v1/pets", "", 200), &payload); err != nil {
t.Fatal(err)
}
return payload.Items
}
valid := `{"name":"团子","species":"cat","breed":"英短","gender":2,"birthday":"2023-05-01",
"neutered":true,"weightG":4200,"vaccinatedAt":"2026-03-10","dewormedAt":"2026-06-01",
"temperament":"怕生,喜欢躲床底","photos":["https://cdn.example.com/p1.jpg","https://cdn.example.com/p2.jpg"]}`
t.Run("a complete profile round-trips with its photos in order", func(t *testing.T) {
if status, body := create(1, valid); status != 200 {
t.Fatalf("HTTP %d: %s", status, body)
}
items := list(1)
if len(items) != 1 {
t.Fatalf("items = %d", len(items))
}
pet := items[0]
if pet.Name != "团子" || pet.SpeciesLabel != "猫" || !pet.Neutered || pet.WeightG != 4200 {
t.Fatalf("pet = %+v", pet)
}
if len(pet.Photos) != 2 || pet.Photos[0] != "https://cdn.example.com/p1.jpg" {
t.Fatalf("photos = %v", pet.Photos)
}
// The first photo becomes the avatar when none was chosen.
if pet.Avatar != "https://cdn.example.com/p1.jpg" {
t.Fatalf("avatar = %q", pet.Avatar)
}
if pet.AgeMonths < 30 {
t.Fatalf("ageMonths = %d,应当由生日推算", pet.AgeMonths)
}
})
t.Run("dates that cannot be true are refused with a reason", func(t *testing.T) {
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
for _, test := range []struct{ body, want string }{
{fmt.Sprintf(`{"name":"未来猫","species":"cat","vaccinatedAt":%q}`, tomorrow), "免疫日期不能晚于今天"},
{fmt.Sprintf(`{"name":"未来猫","species":"cat","birthday":%q}`, tomorrow), "出生日期不能晚于今天"},
{`{"name":"格式错","species":"cat","birthday":"2023/05/01"}`, "出生日期格式应为"},
{`{"name":"太老了","species":"cat","birthday":"1900-01-01"}`, "过于久远"},
} {
status, body := create(1, test.body)
if status != 400 || !strings.Contains(body, test.want) {
t.Fatalf("%s → HTTP %d %s", test.body, status, body)
}
}
})
t.Run("the rest of the form is validated too", func(t *testing.T) {
for _, test := range []struct{ body, want string }{
{`{"name":"","species":"cat"}`, "请填写宠物名字"},
{`{"name":"鹦鹉","species":"bird"}`, "请选择宠物类型"},
{`{"name":"胖胖","species":"dog","weightG":300000}`, "体重应在"},
{`{"name":"图多","species":"cat","photos":["https://a/1.jpg","https://a/2.jpg","https://a/3.jpg","https://a/4.jpg","https://a/5.jpg","https://a/6.jpg","https://a/7.jpg","https://a/8.jpg","https://a/9.jpg","https://a/10.jpg"]}`, "最多上传"},
{`{"name":"坏图","species":"cat","photos":["javascript:alert(1)"]}`, "照片地址无效"},
} {
status, body := create(1, test.body)
if status != 400 || !strings.Contains(body, test.want) {
t.Fatalf("%s → HTTP %d %s", test.body, status, body)
}
}
})
t.Run("editing replaces the photos instead of accumulating them", func(t *testing.T) {
pet := list(1)[0]
w := httptest.NewRecorder()
a.updatePet(w, imTestRequest(1, "PUT", fmt.Sprintf("/api/v1/pets/%d", pet.ID),
`{"name":"团子","species":"cat","photos":["https://cdn.example.com/new.jpg"]}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
updated := list(1)[0]
if len(updated.Photos) != 1 || updated.Photos[0] != "https://cdn.example.com/new.jpg" {
t.Fatalf("photos = %v", updated.Photos)
}
})
t.Run("a profile belongs to its owner alone", func(t *testing.T) {
pet := list(1)[0]
w := httptest.NewRecorder()
a.updatePet(w, imTestRequest(2, "PUT", fmt.Sprintf("/api/v1/pets/%d", pet.ID), valid))
if w.Code != 404 {
t.Fatalf("别人不该改得动: HTTP %d", w.Code)
}
del := httptest.NewRecorder()
a.deletePet(del, imTestRequest(2, "DELETE", fmt.Sprintf("/api/v1/pets/%d", pet.ID), ""))
if del.Code != 404 {
t.Fatalf("别人不该删得掉: HTTP %d", del.Code)
}
})
t.Run("the per-account limit is enforced", func(t *testing.T) {
if _, err := db.Exec(`UPDATE system_configs SET config_value='2' WHERE config_key='pet.max_per_user'`); err != nil {
t.Fatal(err)
}
create(1, `{"name":"第二只","species":"dog"}`)
status, body := create(1, `{"name":"第三只","species":"dog"}`)
if status != 400 || !strings.Contains(body, "最多建立 2 只") {
t.Fatalf("HTTP %d: %s", status, body)
}
if _, err := db.Exec(`UPDATE system_configs SET config_value='10' WHERE config_key='pet.max_per_user'`); err != nil {
t.Fatal(err)
}
})
t.Run("a deleted profile disappears from the list but keeps its row", func(t *testing.T) {
before := list(1)
w := httptest.NewRecorder()
a.deletePet(w, imTestRequest(1, "DELETE", fmt.Sprintf("/api/v1/pets/%d", before[0].ID), ""))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
if len(list(1)) != len(before)-1 {
t.Fatal("删除后列表数量未变")
}
var kept int
if err := db.QueryRow(`SELECT COUNT(*) FROM pets WHERE id=?`, before[0].ID).Scan(&kept); err != nil || kept != 1 {
t.Fatalf("软删除应保留记录: %d %v", kept, err)
}
})
}
func TestAdminPetModerationMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
w := httptest.NewRecorder()
a.createPet(w, imTestRequest(1, "POST", "/api/v1/pets", `{"name":"待审核","species":"dog","breed":"柯基"}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var created struct {
ID int64 `json:"id"`
}
var envelope struct {
Data json.RawMessage `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
_ = json.Unmarshal(envelope.Data, &created)
t.Run("the list carries the owner so a moderator knows whose pet this is", func(t *testing.T) {
var payload struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(imTestCall(t, a.adminPets, 1, "GET", "/admin/v1/pets?keyword=待审核", "", 200), &payload); err != nil {
t.Fatal(err)
}
if payload.Total != 1 || len(payload.Items) != 1 {
t.Fatalf("payload = %+v", payload)
}
if payload.Items[0]["ownerNickname"] == "" || payload.Items[0]["speciesLabel"] != "狗" {
t.Fatalf("item = %v", payload.Items[0])
}
})
t.Run("rejecting without a reason is refused", func(t *testing.T) {
reject := httptest.NewRecorder()
a.adminModeratePet(reject, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pets/%d/moderate", created.ID), `{"status":2}`))
if reject.Code != 400 || !strings.Contains(reject.Body.String(), "必须填写原因") {
t.Fatalf("HTTP %d: %s", reject.Code, reject.Body.String())
}
})
t.Run("a rejected profile stays visible to its owner with the reason", func(t *testing.T) {
reject := httptest.NewRecorder()
a.adminModeratePet(reject, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pets/%d/moderate", created.ID), `{"status":2,"note":"照片看不清宠物"}`))
if reject.Code != 200 {
t.Fatalf("HTTP %d: %s", reject.Code, reject.Body.String())
}
var owner struct {
Pet petView `json:"pet"`
ModerationNote string `json:"moderationNote"`
}
if err := json.Unmarshal(imTestCall(t, a.petDetail, 1, "GET", fmt.Sprintf("/api/v1/pets/%d", created.ID), "", 200), &owner); err != nil {
t.Fatal(err)
}
if owner.Pet.Moderation != 2 || owner.ModerationNote != "照片看不清宠物" {
t.Fatalf("owner view = %+v", owner)
}
// Everyone else stops seeing it until it passes.
stranger := httptest.NewRecorder()
a.petDetail(stranger, imTestRequest(2, "GET", fmt.Sprintf("/api/v1/pets/%d", created.ID), ""))
if stranger.Code != 404 {
t.Fatalf("被驳回的档案不该对外可见: HTTP %d", stranger.Code)
}
})
}