方案第一期的后端部分。宠物档案是整个板块的根——代喂任务、送养帖、 配种信息都会挂在它上面,所以免疫、绝育、体重这些事实只在这里存一份, 不在各业务表里重复填写与互相矛盾。 - pets / pet_media 两张表(迁移 036),另加三个可在管理端调整的配置: 总开关、单账号档案上限、单只宠物照片上限。 - 客户端接口:档案增删改查。日期在校验阶段解析,格式或"未来的免疫日期" 这类问题返回 400 并说明是哪个字段,而不是从写库深处抛出 500。 - 软删除:档案从列表消失但记录保留,将来代喂订单与送养记录仍能追溯。 - 后台审核:列表带主人昵称与星遇号;驳回必须填原因;被驳回的档案对外 不可见,但主人仍能看到并读到原因。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
8.4 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|