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