package app import ( "encoding/json" "fmt" "net/http/httptest" "strings" "testing" ) // petWithPhotos creates a profile complete enough to be given away: photos to // look at and a vaccination date to trust. func petWithPhotos(t *testing.T, a *App, owner int64, name string, photos int) int64 { t.Helper() urls := make([]string, 0, photos) for index := 0; index < photos; index++ { urls = append(urls, fmt.Sprintf("https://cdn.example.com/%s-%d.jpg", name, index)) } encoded, _ := json.Marshal(urls) body := fmt.Sprintf(`{"name":%q,"species":"cat","breed":"狸花","gender":2,"birthday":"2023-01-01","vaccinatedAt":"2026-06-01","photos":%s}`, name, encoded) w := httptest.NewRecorder() a.createPet(w, imTestRequest(owner, "POST", "/api/v1/pets", body)) if w.Code != 200 { t.Fatalf("建立宠物档案失败: %s", w.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &envelope) return envelope.Data.ID } // Adoption is an information service with one hard rule: no money. Everything // below exists to keep it that way and to make the handover accountable. func TestPetAdoptionMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} publish := func(owner, petID int64, reason string) (int, string) { t.Helper() w := httptest.NewRecorder() body := fmt.Sprintf(`{"petId":%d,"cityCode":"440300","cityName":"深圳","reason":%q,"requirements":"希望能定期反馈近况"}`, petID, reason) a.createAdoption(w, imTestRequest(owner, "POST", "/api/v1/pet/adoptions", body)) return w.Code, w.Body.String() } answers := `{"answers":{"housing":"自有住房","members":"家人都同意","pets":"没有","experience":"养过五年猫","followup":"接受"},"message":"想给它一个家"}` t.Run("a profile without photos or vaccination cannot be given away", func(t *testing.T) { bare := petWithPhotos(t, a, 1, "无照片", 0) if status, body := publish(1, bare, "工作变动无法继续照顾"); status != 400 || !strings.Contains(body, "至少上传") { t.Fatalf("HTTP %d: %s", status, body) } }) t.Run("a complete listing waits for review before anyone sees it", func(t *testing.T) { pet := petWithPhotos(t, a, 1, "团子", 3) status, body := publish(1, pet, "工作变动,实在没有精力继续照顾") if status != 200 { t.Fatalf("HTTP %d: %s", status, body) } var shelf struct { Items []adoptionView `json:"items"` } if err := json.Unmarshal(imTestCall(t, a.adoptions, 2, "GET", "/api/v1/pet/adoptions", "", 200), &shelf); err != nil { t.Fatal(err) } if len(shelf.Items) != 0 { t.Fatal("未审核的送养信息不该出现在列表里") } // The giver still sees their own listing, pending or not. var mine struct { Items []adoptionView `json:"items"` } if err := json.Unmarshal(imTestCall(t, a.adoptions, 1, "GET", "/api/v1/pet/adoptions?mine=1", "", 200), &mine); err != nil { t.Fatal(err) } if len(mine.Items) != 1 || mine.Items[0].Moderation != 0 { t.Fatalf("mine = %+v", mine.Items) } }) t.Run("anything that smells like a price is flagged for a human", func(t *testing.T) { pet := petWithPhotos(t, a, 3, "收费猫", 3) _, body := publish(3, pet, "因搬家送养,需要象征性收取一点疫苗费用") if !strings.Contains(body, "疫苗") && !strings.Contains(body, "费用") { t.Fatalf("命中敏感词时应当说明原因: %s", body) } var flagged string if err := db.QueryRow(`SELECT review_reason FROM pet_adoptions WHERE owner_user_id=3 ORDER BY id DESC LIMIT 1`).Scan(&flagged); err != nil { t.Fatal(err) } if flagged == "" { t.Fatal("送养文案含收费话术时必须标记转人工") } }) t.Run("the whole handover: approve, apply, choose, schedule follow-ups", func(t *testing.T) { var adoptionID int64 if err := db.QueryRow(`SELECT id FROM pet_adoptions WHERE owner_user_id=1 ORDER BY id DESC LIMIT 1`).Scan(&adoptionID); err != nil { t.Fatal(err) } approve := httptest.NewRecorder() a.adminModeratePetListing(approve, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/listings/%d/moderate", adoptionID), `{"kind":"adoption","status":1}`)) if approve.Code != 200 { t.Fatalf("审核通过失败: %s", approve.Body.String()) } apply := httptest.NewRecorder() a.applyAdoption(apply, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), answers)) if apply.Code != 200 { t.Fatalf("申请失败: %s", apply.Body.String()) } // Applying twice is impossible, not merely discouraged. again := httptest.NewRecorder() a.applyAdoption(again, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), answers)) if again.Code != 400 { t.Fatalf("重复申请应被拒绝: HTTP %d", again.Code) } // The giver sees the answers; a stranger does not. var detail struct { Applications []map[string]any `json:"applications"` } if err := json.Unmarshal(imTestCall(t, a.adoptionDetail, 1, "GET", fmt.Sprintf("/api/v1/pet/adoptions/%d", adoptionID), "", 200), &detail); err != nil { t.Fatal(err) } if len(detail.Applications) != 1 { t.Fatalf("送养人应当看到申请: %+v", detail) } var stranger struct { Applications []map[string]any `json:"applications"` } if err := json.Unmarshal(imTestCall(t, a.adoptionDetail, 3, "GET", fmt.Sprintf("/api/v1/pet/adoptions/%d", adoptionID), "", 200), &stranger); err != nil { t.Fatal(err) } if len(stranger.Applications) != 0 { t.Fatal("问卷答案里有别人住在哪,不能给无关的人看") } applicationID := int64(detail.Applications[0]["id"].(float64)) decide := httptest.NewRecorder() a.decideAdoption(decide, imTestRequest(1, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/decide", adoptionID), fmt.Sprintf(`{"applicationId":%d,"action":"accept"}`, applicationID))) if decide.Code != 200 { t.Fatalf("选定失败: %s", decide.Body.String()) } var status int var adopter int64 if err := db.QueryRow(`SELECT status,adopter_user_id FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&status, &adopter); err != nil { t.Fatal(err) } if status != 2 || adopter != 2 { t.Fatalf("status=%d adopter=%d", status, adopter) } // Follow-ups are created with the handover, not by something remembering later. var followups int if err := db.QueryRow(`SELECT COUNT(*) FROM pet_adoption_followups WHERE adoption_id=? AND adopter_user_id=2`, adoptionID).Scan(&followups); err != nil { t.Fatal(err) } if followups != 2 { t.Fatalf("回访任务 = %d,应当按 30/90 天各建一条", followups) } // A closed listing takes no more applications. late := httptest.NewRecorder() a.applyAdoption(late, imTestRequest(3, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), answers)) if late.Code != 400 { t.Fatalf("已送出的信息不该再接受申请: HTTP %d", late.Code) } }) t.Run("the adopter reports back and the record shows it", func(t *testing.T) { var followupID int64 if err := db.QueryRow(`SELECT id FROM pet_adoption_followups WHERE adopter_user_id=2 ORDER BY due_at LIMIT 1`).Scan(&followupID); err != nil { t.Fatal(err) } submit := httptest.NewRecorder() a.submitAdoptionFollowup(submit, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/followups/%d", followupID), `{"content":"已经适应新家,胖了两斤","media":["https://cdn.example.com/followup.jpg"]}`)) if submit.Code != 200 { t.Fatalf("HTTP %d: %s", submit.Code, submit.Body.String()) } twice := httptest.NewRecorder() a.submitAdoptionFollowup(twice, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/followups/%d", followupID), `{"content":"再来一次"}`)) if twice.Code != 404 { t.Fatalf("同一条回访不该重复提交: HTTP %d", twice.Code) } var list struct { Items []map[string]any `json:"items"` } if err := json.Unmarshal(imTestCall(t, a.myAdoptionFollowups, 2, "GET", "/api/v1/pet/followups", "", 200), &list); err != nil { t.Fatal(err) } // 待办排在前面:这个列表是用来提醒人去做事的,不是用来存档的。 if len(list.Items) != 2 || list.Items[0]["submitted"] != false || list.Items[1]["submitted"] != true { t.Fatalf("未提交的回访应当排在前面: %+v", list.Items) } }) t.Run("an incomplete questionnaire is refused with the missing question", func(t *testing.T) { pet := petWithPhotos(t, a, 3, "第二只", 3) publish(3, pet, "家里过敏,只能忍痛送养") var adoptionID int64 if err := db.QueryRow(`SELECT id FROM pet_adoptions WHERE owner_user_id=3 ORDER BY id DESC LIMIT 1`).Scan(&adoptionID); err != nil { t.Fatal(err) } if _, err := db.Exec(`UPDATE pet_adoptions SET moderation_status=1 WHERE id=?`, adoptionID); err != nil { t.Fatal(err) } w := httptest.NewRecorder() a.applyAdoption(w, imTestRequest(1, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), `{"answers":{"housing":"租住"},"message":""}`)) if w.Code != 400 || !strings.Contains(w.Body.String(), "家人") { t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) } }) }