领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工, 送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。 代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定 之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认 后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次; 申诉会冻结自动确认,由客服裁定全付、部分付或全退。 提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
9.1 KiB
Go
217 lines
9.1 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|