Files
kefu/im/backend/internal/app/pet_adoption_integration_test.go
T
Your NameandClaude Opus 5 46a60ffac3 宠物板块二到四期:领养、配种、上门代喂与收益账户
领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工,
送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。

代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定
之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认
后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次;
申诉会冻结自动确认,由客服裁定全付、部分付或全退。

提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:14:19 +08:00

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