package app import ( "context" "database/sql" "encoding/json" "fmt" "net/http/httptest" "strings" "testing" "time" ) // The feeding module moves real money, so these tests are written around the // two questions an auditor would ask: can the platform pay twice, and can it // pay for work nobody proved happened. func TestPetFeedTaskMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} // Encryption of the door number and phone is not optional in this flow. a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner, sitter := int64(1), int64(2) petID := petWithPhotos(t, a, owner, "咪咪", 3) var addressID int64 t.Run("an address keeps its door number encrypted", func(t *testing.T) { // 定位拿不到也要能存下来,只是这条地址此后打卡不做距离核验。 w := httptest.NewRecorder() a.createPetAddress(w, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityName":"深圳","areaText":"南山区科技园","detail":"3 栋 1201","contact":"张先生 13800000000"}`)) if w.Code != 200 || !strings.Contains(w.Body.String(), `"located":false`) { t.Fatalf("没有定位也该能保存并标记未核验: HTTP %d %s", w.Code, w.Body.String()) } ok := httptest.NewRecorder() a.createPetAddress(ok, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityName":"深圳","areaText":"南山区科技园","detail":"3 栋 1201","contact":"张先生 13800000000","latitude":22.5411,"longitude":113.9345}`)) if ok.Code != 200 { t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(ok.Body.Bytes(), &envelope) addressID = envelope.Data.ID // The plain text must not be readable from the row itself. var detail []byte if err := db.QueryRow(`SELECT detail_cipher FROM pet_addresses WHERE id=?`, addressID).Scan(&detail); err != nil { t.Fatal(err) } if strings.Contains(string(detail), "1201") { t.Fatal("门牌号必须加密存储") } }) var taskID int64 t.Run("publishing plans every visit up front", func(t *testing.T) { body := fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-03","visitsPerDay":2, "minMinutes":20,"priceCent":3000,"note":"猫粮在阳台","items":[{"kind":"feed","amount":"40 克"},{"kind":"water","amount":""}]}`, petID, addressID) w := httptest.NewRecorder() a.createFeedTask(w, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", body)) if w.Code != 200 { t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` GrossCent int64 `json:"grossCent"` PayoutCent int64 `json:"payoutCent"` TotalVisits int `json:"totalVisits"` } `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &envelope) taskID = envelope.Data.ID if envelope.Data.TotalVisits != 6 || envelope.Data.GrossCent != 18000 { t.Fatalf("3 天每天 2 次 30 元应当是 6 次 180 元: %+v", envelope.Data) } if envelope.Data.PayoutCent != 15300 { t.Fatalf("默认抽成 15%%,喂养者应得 153 元: %d", envelope.Data.PayoutCent) } var visits int if err := db.QueryRow(`SELECT COUNT(*) FROM pet_feed_visits WHERE task_id=?`, taskID).Scan(&visits); err != nil { t.Fatal(err) } if visits != 6 { t.Fatalf("上门排期 = %d,应当发布时就排好", visits) } }) t.Run("an unfunded task is invisible and cannot be taken", func(t *testing.T) { var hall struct { Items []feedTaskView `json:"items"` } if err := json.Unmarshal(imTestCall(t, a.feedTasks, sitter, "GET", "/api/v1/pet/feed-tasks", "", 200), &hall); err != nil { t.Fatal(err) } if len(hall.Items) != 0 { t.Fatal("未支付的任务不该出现在大厅里") } w := httptest.NewRecorder() a.applyFeedTask(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"我可以"}`)) if w.Code != 403 { t.Fatalf("没有喂养者资格就不该能报名: HTTP %d %s", w.Code, w.Body.String()) } }) t.Run("a sitter needs a verified identity and a deposit", func(t *testing.T) { w := httptest.NewRecorder() a.applySitter(w, imTestRequest(sitter, "POST", "/api/v1/pet/sitter", `{"cityName":"深圳","radiusM":5000,"intro":"养猫五年,会喂药和处理应激"}`)) if w.Code != 403 { t.Fatalf("未实名不该能成为喂养者: HTTP %d %s", w.Code, w.Body.String()) } if _, err := db.Exec(`INSERT INTO user_verifications(user_id,verification_type,real_name,status,evidence_json) VALUES(?,'real_name','李四','VERIFIED','[]')`, sitter); err != nil { t.Fatal(err) } again := httptest.NewRecorder() a.applySitter(again, imTestRequest(sitter, "POST", "/api/v1/pet/sitter", `{"cityName":"深圳","radiusM":5000,"intro":"养猫五年,会喂药和处理应激"}`)) if again.Code != 200 { t.Fatalf("HTTP %d: %s", again.Code, again.Body.String()) } // Pending review: still cannot take work. payFeedTask(t, a, owner, taskID) blocked := httptest.NewRecorder() a.applyFeedTask(blocked, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"我可以"}`)) if blocked.Code != 403 { t.Fatalf("审核未通过就不该能接单: HTTP %d %s", blocked.Code, blocked.Body.String()) } review := httptest.NewRecorder() a.adminReviewSitter(review, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/sitters/%d/review", sitter), `{"status":1,"note":"","depositCent":20000}`)) if review.Code != 200 { t.Fatalf("审核失败: %s", review.Body.String()) } }) var applicationID int64 t.Run("the address is released only to the chosen sitter", func(t *testing.T) { apply := httptest.NewRecorder() a.applyFeedTask(apply, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"住在附近,走路十分钟"}`)) if apply.Code != 200 { t.Fatalf("HTTP %d: %s", apply.Code, apply.Body.String()) } // Before being chosen, the sitter sees the street but not the door. var early struct { Address map[string]any `json:"address"` } if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, sitter, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &early); err != nil { t.Fatal(err) } if early.Address != nil { t.Fatal("还没被选中就不该看到门牌与联系方式") } var detail struct { Applications []map[string]any `json:"applications"` } if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, owner, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &detail); err != nil { t.Fatal(err) } if len(detail.Applications) != 1 { t.Fatalf("主人应当看到报名: %+v", detail) } applicationID = int64(detail.Applications[0]["id"].(float64)) assign := httptest.NewRecorder() a.assignFeedTask(assign, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/assign", taskID), fmt.Sprintf(`{"applicationId":%d}`, applicationID))) if assign.Code != 200 { t.Fatalf("HTTP %d: %s", assign.Code, assign.Body.String()) } var after struct { Address struct { Contact string `json:"contact"` Detail string `json:"detail"` } `json:"address"` } if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, sitter, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &after); err != nil { t.Fatal(err) } if after.Address.Detail != "3 栋 1201" || !strings.Contains(after.Address.Contact, "13800000000") { t.Fatalf("选定后喂养者应当看到门牌与联系方式: %+v", after.Address) } }) t.Run("a visit cannot be closed without its photos", func(t *testing.T) { visitID := firstVisit(t, db, taskID) early := httptest.NewRecorder() a.finishFeedVisit(early, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/finish", visitID), ``)) if early.Code != 400 { t.Fatalf("没有打卡照片就不该能提交: HTTP %d %s", early.Code, early.Body.String()) } // A check-in from the other side of the city is recorded, and flagged. far := httptest.NewRecorder() a.checkinFeedVisit(far, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), `{"step":"arrive","mediaUrl":"https://cdn.example.com/arrive.jpg","latitude":22.7,"longitude":114.2}`)) if far.Code != 200 { t.Fatalf("HTTP %d: %s", far.Code, far.Body.String()) } var verdict string if err := db.QueryRow(`SELECT verdict FROM pet_feed_checkins WHERE visit_id=? AND step='arrive'`, visitID).Scan(&verdict); err != nil { t.Fatal(err) } if verdict != "far" { t.Fatalf("离地址很远的打卡应当被标记,verdict=%s", verdict) } duplicate := httptest.NewRecorder() a.checkinFeedVisit(duplicate, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), `{"step":"arrive","mediaUrl":"https://cdn.example.com/arrive2.jpg","latitude":22.5411,"longitude":113.9345}`)) if duplicate.Code != 400 { t.Fatalf("同一个环节不该能打两次卡: HTTP %d", duplicate.Code) } }) t.Run("the whole service completes and pays exactly once", func(t *testing.T) { // 打卡要按顺序、并且喂食离到达有间隔,所以时间戳是分开给的。 for _, visitID := range allVisits(t, db, taskID) { base := time.Now() for index, step := range []string{"arrive", "feed", "leave"} { w := httptest.NewRecorder() a.checkinFeedVisit(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345,"capturedAt":%q}`, step, step, base.Add(time.Duration(index*3)*time.Minute).Format(time.RFC3339)))) // The first visit already has an 'arrive' from the previous case. if w.Code != 200 && w.Code != 400 { t.Fatalf("打卡失败: HTTP %d %s", w.Code, w.Body.String()) } } finish := httptest.NewRecorder() a.finishFeedVisit(finish, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/finish", visitID), ``)) if finish.Code != 200 { t.Fatalf("提交上门失败: HTTP %d %s", finish.Code, finish.Body.String()) } } var status string if err := db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status); err != nil { t.Fatal(err) } if status != "COMPLETED" { t.Fatalf("所有上门完成后任务应当进入待确认: %s", status) } confirm := httptest.NewRecorder() a.confirmFeedTask(confirm, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/confirm", taskID), ``)) if confirm.Code != 200 { t.Fatalf("确认失败: %s", confirm.Body.String()) } var available int64 if err := db.QueryRow(`SELECT available_cent FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&available); err != nil { t.Fatal(err) } if available != 15300 { t.Fatalf("结算金额 = %d,应当是扣除抽成后的 15300", available) } // Settling again — by a retried worker, say — must not pay twice. if err := a.settleFeedTask(context.Background(), taskID, "重复结算"); err == nil { t.Fatal("已结算的任务不该能再次结算") } a.autoConfirmFeedTasks(context.Background()) var after int64 if err := db.QueryRow(`SELECT available_cent FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&after); err != nil { t.Fatal(err) } if after != 15300 { t.Fatalf("余额被重复入账: %d", after) } var entries int if err := db.QueryRow(`SELECT COUNT(*) FROM wallet_transactions WHERE user_id=? AND biz_type='feed_settle'`, sitter).Scan(&entries); err != nil { t.Fatal(err) } if entries != 1 { t.Fatalf("结算流水 = %d,应当只有一条", entries) } }) t.Run("reviews stay hidden until both sides write one", func(t *testing.T) { byOwner := httptest.NewRecorder() a.reviewFeedTask(byOwner, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/review", taskID), `{"punctual":5,"accurate":4,"communication":5,"content":"每次都按时到,照片拍得很清楚"}`)) if byOwner.Code != 200 { t.Fatalf("HTTP %d: %s", byOwner.Code, byOwner.Body.String()) } var visible struct { Items []map[string]any `json:"items"` } if err := json.Unmarshal(imTestCall(t, a.sitterReviews, owner, "GET", fmt.Sprintf("/api/v1/pet/sitters/%d/reviews", sitter), "", 200), &visible); err != nil { t.Fatal(err) } if len(visible.Items) != 0 { t.Fatal("只有一方评价时不该公开,否则另一方会照着写") } bySitter := httptest.NewRecorder() a.reviewFeedTask(bySitter, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/review", taskID), `{"punctual":5,"accurate":5,"communication":5,"content":"主人交代得很清楚"}`)) if bySitter.Code != 200 { t.Fatalf("HTTP %d: %s", bySitter.Code, bySitter.Body.String()) } var opened struct { Items []map[string]any `json:"items"` } if err := json.Unmarshal(imTestCall(t, a.sitterReviews, owner, "GET", fmt.Sprintf("/api/v1/pet/sitters/%d/reviews", sitter), "", 200), &opened); err != nil { t.Fatal(err) } if len(opened.Items) != 1 { t.Fatalf("双方都评价后应当公开: %+v", opened.Items) } }) } // A dispute is the case where the money must not move on a timer. func TestPetFeedDisputeMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner, sitter := int64(1), int64(2) taskID := seedAssignedTask(t, a, db, owner, sitter) raise := httptest.NewRecorder() a.raiseFeedDispute(raise, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/dispute", taskID), `{"reason":"第二天的照片是白天拍的,但约定是晚上八点,猫粮碗也没有动过"}`)) if raise.Code != 200 { t.Fatalf("HTTP %d: %s", raise.Code, raise.Body.String()) } var status string var confirmDue any if err := db.QueryRow(`SELECT status,confirm_due_at FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &confirmDue); err != nil { t.Fatal(err) } if status != "DISPUTED" || confirmDue != nil { t.Fatalf("申诉必须冻结自动确认: status=%s confirmDue=%v", status, confirmDue) } // The timer must not pay out while a human still has to look at it. if settled := a.autoConfirmFeedTasks(context.Background()); settled != 0 { t.Fatalf("申诉中的任务被自动结算了 %d 笔", settled) } var disputeID int64 if err := db.QueryRow(`SELECT id FROM pet_feed_disputes WHERE task_id=?`, taskID).Scan(&disputeID); err != nil { t.Fatal(err) } short := httptest.NewRecorder() a.adminHandleFeedDispute(short, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), `{"verdict":"refund","note":"好"}`)) if short.Code != 400 { t.Fatalf("裁定必须写理由: HTTP %d", short.Code) } handle := httptest.NewRecorder() a.adminHandleFeedDispute(handle, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), `{"verdict":"split","payoutCent":5000,"note":"前两次有完整照片,第三次证据不足,按两次计算"}`)) if handle.Code != 200 { t.Fatalf("HTTP %d: %s", handle.Code, handle.Body.String()) } var available int64 if err := db.QueryRow(`SELECT available_cent FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&available); err != nil { t.Fatal(err) } if available != 5000 { t.Fatalf("部分支付应当只入账裁定的金额: %d", available) } twice := httptest.NewRecorder() a.adminHandleFeedDispute(twice, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), `{"verdict":"payout","note":"再判一次看看会不会重复打钱"}`)) if twice.Code != 400 { t.Fatalf("同一条申诉不该能裁定两次: HTTP %d", twice.Code) } } // payFeedTask walks the real money path: create the order, pay it in sandbox, // and check the task is only funded because the payment landed. func payFeedTask(t *testing.T, a *App, owner, taskID int64) { t.Helper() order := httptest.NewRecorder() a.createFeedTaskOrder(order, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) if order.Code != 200 { t.Fatalf("下单失败: %s", order.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(order.Body.Bytes(), &envelope) paid := httptest.NewRecorder() a.payOrder(paid, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", envelope.Data.ID), ``)) if paid.Code != 200 { t.Fatalf("支付失败: %s", paid.Body.String()) } } func firstVisit(t *testing.T, db *sql.DB, taskID int64) int64 { t.Helper() visits := allVisits(t, db, taskID) if len(visits) == 0 { t.Fatal("任务没有排期") } return visits[0] } func allVisits(t *testing.T, db *sql.DB, taskID int64) []int64 { t.Helper() rows, err := db.Query(`SELECT id FROM pet_feed_visits WHERE task_id=? ORDER BY visit_date,sequence`, taskID) if err != nil { t.Fatal(err) } defer rows.Close() visits := []int64{} for rows.Next() { var id int64 if rows.Scan(&id) == nil { visits = append(visits, id) } } return visits } // seedAssignedTask fast-forwards a task to the state a dispute starts from: paid // for, assigned, and reported complete. The steps in between have their own // tests above; repeating them here would only make this one slower to read. func seedAssignedTask(t *testing.T, a *App, db *sql.DB, owner, sitter int64) int64 { t.Helper() petID := petWithPhotos(t, a, owner, "豆豆", 3) address := httptest.NewRecorder() a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityName":"深圳","areaText":"福田区","detail":"5 栋 302","contact":"王女士 13900000000","latitude":22.5411,"longitude":113.9345}`)) if address.Code != 200 { t.Fatalf("地址创建失败: %s", address.Body.String()) } var addressEnvelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope) task := httptest.NewRecorder() a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":3, "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) if task.Code != 200 { t.Fatalf("任务创建失败: %s", task.Body.String()) } var taskEnvelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope) taskID := taskEnvelope.Data.ID if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='COMPLETED',sitter_user_id=?,completed_visits=total_visits, confirm_due_at=DATE_SUB(NOW(3),INTERVAL 1 HOUR) WHERE id=?`, sitter, taskID); err != nil { t.Fatal(err) } return taskID } // The overview is what an operator opens first; if its numbers drift from the // lists behind them, they stop trusting the console and go back to SQL. func TestPetOverviewMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner, sitter := int64(1), int64(2) read := func() map[string]any { t.Helper() var view map[string]any if err := json.Unmarshal(imTestCall(t, a.adminPetOverview, 1, "GET", "/admin/v1/pet/overview", "", 200), &view); err != nil { t.Fatal(err) } return view } number := func(view map[string]any, group, key string) int64 { t.Helper() section, ok := view[group].(map[string]any) if !ok { t.Fatalf("%s 不是一组数字: %+v", group, view[group]) } return int64(section[key].(float64)) } empty := read() if number(empty, "pending", "disputes") != 0 || number(empty, "money", "escrowedCent") != 0 { t.Fatalf("空库时待办与代管金额应当都是 0: %+v", empty) } taskID := seedAssignedTask(t, a, db, owner, sitter) // seedAssignedTask 停在"待确认":钱已经收了,还没结算给任何人。 held := read() if number(held, "money", "escrowedCent") != 9000 { t.Fatalf("代管金额 = %d,应当是 3 次 30 元", number(held, "money", "escrowedCent")) } if number(held, "tasks", "COMPLETED") != 1 { t.Fatalf("待确认任务数不对: %+v", held["tasks"]) } raise := httptest.NewRecorder() a.raiseFeedDispute(raise, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/dispute", taskID), `{"reason":"照片对不上,猫粮碗一直是满的,怀疑没有真的上门"}`)) if raise.Code != 200 { t.Fatalf("HTTP %d: %s", raise.Code, raise.Body.String()) } disputed := read() if number(disputed, "pending", "disputes") != 1 { t.Fatalf("待处理申诉应当是 1: %+v", disputed["pending"]) } // 申诉中的任务仍算代管:钱既没退也没结。 if number(disputed, "money", "escrowedCent") != 9000 { t.Fatalf("申诉期间代管金额不该消失: %d", number(disputed, "money", "escrowedCent")) } var disputeID int64 if err := db.QueryRow(`SELECT id FROM pet_feed_disputes WHERE task_id=?`, taskID).Scan(&disputeID); err != nil { t.Fatal(err) } handle := httptest.NewRecorder() a.adminHandleFeedDispute(handle, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), `{"verdict":"payout","note":"照片与定位都对得上,判全额支付"}`)) if handle.Code != 200 { t.Fatalf("HTTP %d: %s", handle.Code, handle.Body.String()) } settled := read() if number(settled, "pending", "disputes") != 0 || number(settled, "money", "escrowedCent") != 0 { t.Fatalf("裁定之后待办与代管都应当归零: %+v", settled) } if number(settled, "money", "walletBalanceCent") != number(settled, "money", "settledCent") { t.Fatalf("已结算金额与账户余额应当一致: %+v", settled["money"]) } // 账实一致是继续打款的前提,概览要能自己回答这件事。 if settled["ledgerMismatches"].(float64) != 0 { t.Fatalf("对账出现差异: %+v", settled) } } // Payment is what actually funds a task now, so the states either side of it // matter: nothing before the money, no double effect after it, and a refund // that cannot quietly reverse a payout that has already been made. func TestPetOrderPaymentMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner, sitter := int64(1), int64(2) petID := petWithPhotos(t, a, owner, "汤圆", 3) address := httptest.NewRecorder() a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityName":"深圳","areaText":"福田区","detail":"5 栋 302","contact":"王女士 13900000000","latitude":22.5411,"longitude":113.9345}`)) var addressEnvelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope) task := httptest.NewRecorder() a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":2, "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) if task.Code != 200 { t.Fatalf("HTTP %d: %s", task.Code, task.Body.String()) } var taskEnvelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope) taskID := taskEnvelope.Data.ID orderID := int64(0) t.Run("the order carries the real price of the task and is reused, not duplicated", func(t *testing.T) { first := httptest.NewRecorder() a.createFeedTaskOrder(first, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) if first.Code != 200 { t.Fatalf("HTTP %d: %s", first.Code, first.Body.String()) } var envelope struct { Data struct { AmountCent int64 `json:"amountCent"` ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(first.Body.Bytes(), &envelope) orderID = envelope.Data.ID if envelope.Data.AmountCent != 6000 { t.Fatalf("订单金额 = %d,应当等于任务总价", envelope.Data.AmountCent) } second := httptest.NewRecorder() a.createFeedTaskOrder(second, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) var again struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(second.Body.Bytes(), &again) if again.Data.ID != orderID { t.Fatalf("重复下单应当复用未支付的订单: %d vs %d", again.Data.ID, orderID) } // 别人的任务不能替他付。 stranger := httptest.NewRecorder() a.createFeedTaskOrder(stranger, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) if stranger.Code != 403 { t.Fatalf("HTTP %d", stranger.Code) } }) t.Run("the task is funded by the payment, and paying twice changes nothing", func(t *testing.T) { var status string _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status) if status != "CREATED" { t.Fatalf("下单还没付款,任务不该进入大厅: %s", status) } paid := httptest.NewRecorder() a.payOrder(paid, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", orderID), ``)) if paid.Code != 200 { t.Fatalf("HTTP %d: %s", paid.Code, paid.Body.String()) } var linked int64 if err := db.QueryRow(`SELECT status,COALESCE(order_id,0) FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &linked); err != nil { t.Fatal(err) } if status != "ESCROWED" || linked != orderID { t.Fatalf("付款后任务应当托管并记住订单: status=%s order=%d", status, linked) } twice := httptest.NewRecorder() a.payOrder(twice, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", orderID), ``)) if twice.Code != 200 { t.Fatalf("重复支付应当幂等: HTTP %d %s", twice.Code, twice.Body.String()) } var orders int if err := db.QueryRow(`SELECT COUNT(*) FROM orders WHERE product_type='pet_feed' AND product_id=?`, taskID).Scan(&orders); err != nil { t.Fatal(err) } if orders != 1 { t.Fatalf("同一个任务不该产生 %d 笔订单", orders) } }) t.Run("a refund releases an escrowed task but never a settled one", func(t *testing.T) { tx, err := db.Begin() if err != nil { t.Fatal(err) } if err = a.revokePaidOrderTx(context.Background(), tx, orderID, owner, productPetFeed, taskID, 6000); err != nil { t.Fatalf("退款失败: %v", err) } if err = tx.Commit(); err != nil { t.Fatal(err) } var status string _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status) if status != "REFUNDED" { t.Fatalf("退款后任务应当关闭: %s", status) } // 已结算的任务:钱已经在喂养者账上,不能靠一条退款通知悄悄冲掉。 if _, err = db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED' WHERE id=?`, taskID); err != nil { t.Fatal(err) } tx, err = db.Begin() if err != nil { t.Fatal(err) } defer func() { _ = tx.Rollback() }() if err = a.revokePaidOrderTx(context.Background(), tx, orderID, owner, productPetFeed, taskID, 6000); err == nil { t.Fatal("已结算的任务不该能直接退款") } }) t.Run("the sitter deposit is paid the same way and lands on the profile", func(t *testing.T) { early := httptest.NewRecorder() a.createDepositOrder(early, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`)) if early.Code != 403 { t.Fatalf("没有喂养者资料时不该能交保证金: HTTP %d", early.Code) } if _, err := db.Exec(`INSERT INTO user_verifications(user_id,verification_type,real_name,status,evidence_json) VALUES(?,'real_name','李四','VERIFIED','[]')`, sitter); err != nil { t.Fatal(err) } apply := httptest.NewRecorder() a.applySitter(apply, imTestRequest(sitter, "POST", "/api/v1/pet/sitter", `{"cityName":"深圳","radiusM":5000,"intro":"养猫五年,会喂药和处理应激"}`)) if apply.Code != 200 { t.Fatalf("HTTP %d: %s", apply.Code, apply.Body.String()) } order := httptest.NewRecorder() a.createDepositOrder(order, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`)) if order.Code != 200 { t.Fatalf("HTTP %d: %s", order.Code, order.Body.String()) } var envelope struct { Data struct { AmountCent int64 `json:"amountCent"` ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(order.Body.Bytes(), &envelope) if envelope.Data.AmountCent != 20000 { t.Fatalf("保证金金额 = %d,应当是配置的 200 元", envelope.Data.AmountCent) } paid := httptest.NewRecorder() a.payOrder(paid, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", envelope.Data.ID), ``)) if paid.Code != 200 { t.Fatalf("HTTP %d: %s", paid.Code, paid.Body.String()) } var deposit int if err := db.QueryRow(`SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, sitter).Scan(&deposit); err != nil { t.Fatal(err) } if deposit != 20000 { t.Fatalf("保证金 = %d", deposit) } full := httptest.NewRecorder() a.createDepositOrder(full, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`)) if full.Code != 400 { t.Fatalf("交满之后不该能重复下单: HTTP %d", full.Code) } }) t.Run("an operator can fund a task by hand, and it is written down", func(t *testing.T) { manual := seedTaskAwaitingPayment(t, a, db, owner) thin := httptest.NewRecorder() a.adminEscrowFeedTask(thin, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/escrow", manual), `{"note":"收到"}`)) if thin.Code != 400 { t.Fatalf("手工托管必须写清楚钱怎么收的: HTTP %d", thin.Code) } ok := httptest.NewRecorder() a.adminEscrowFeedTask(ok, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/escrow", manual), `{"note":"灰度期线下微信收款,流水号 20260907001"}`)) if ok.Code != 200 { t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String()) } var status string _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, manual).Scan(&status) if status != "ESCROWED" { t.Fatalf("手工托管后状态 = %s", status) } }) } func seedTaskAwaitingPayment(t *testing.T, a *App, db *sql.DB, owner int64) int64 { t.Helper() petID := petWithPhotos(t, a, owner, fmt.Sprintf("手工%d", time.Now().UnixNano()%1000), 3) address := httptest.NewRecorder() a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityName":"深圳","areaText":"罗湖区","detail":"1 栋 101","contact":"张先生 13800000000","latitude":22.5411,"longitude":113.9345}`)) var addressEnvelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope) task := httptest.NewRecorder() a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-05","endDate":"2026-10-05","visitsPerDay":1, "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) if task.Code != 200 { t.Fatalf("HTTP %d: %s", task.Code, task.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(task.Body.Bytes(), &envelope) return envelope.Data.ID } // The rules that make a check-in evidence rather than a formality: it has to // happen in order, and the feeding photo cannot be taken in the same breath as // the arrival one. func TestPetCheckinDisciplineMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner, sitter := int64(1), int64(2) taskID := seedTaskAwaitingPayment(t, a, db, owner) if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=?`, sitter, taskID); err != nil { t.Fatal(err) } visitID := firstVisit(t, db, taskID) checkin := func(step string, at time.Time) (int, string) { t.Helper() w := httptest.NewRecorder() body := fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345,"capturedAt":%q}`, step, step, at.Format(time.RFC3339)) a.checkinFeedVisit(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), body)) return w.Code, w.Body.String() } now := time.Now() if status, body := checkin("feed", now); status != 400 || !strings.Contains(body, "到达") { t.Fatalf("没到门口就先拍喂食,应当被拦下: HTTP %d %s", status, body) } if status, body := checkin("arrive", now); status != 200 { t.Fatalf("到达打卡失败: HTTP %d %s", status, body) } if status, body := checkin("feed", now.Add(20*time.Second)); status != 400 || !strings.Contains(body, "间隔") { t.Fatalf("到达后 20 秒就拍喂食,应当被拦下: HTTP %d %s", status, body) } if status, body := checkin("leave", now.Add(5*time.Minute)); status != 400 || !strings.Contains(body, "喂食") { t.Fatalf("还没喂就走,应当被拦下: HTTP %d %s", status, body) } if status, body := checkin("feed", now.Add(3*time.Minute)); status != 200 { t.Fatalf("间隔够了应当放行: HTTP %d %s", status, body) } if status, body := checkin("leave", now.Add(20*time.Minute)); status != 200 { t.Fatalf("离开打卡失败: HTTP %d %s", status, body) } } // Two promises the schema makes and the code has to keep: the sitter gets the // door number only while the job is live, and the plaintext goes away after it. func TestPetAddressLifecycleMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner, sitter := int64(1), int64(2) create := httptest.NewRecorder() a.createPetAddress(create, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityName":"深圳","areaText":"南山区","detail":"3 栋 1201","contact":"张先生 13800000000","emergency":"李阿姨 13700000000","vetHospital":"科苑宠物医院","latitude":22.5411,"longitude":113.9345}`)) if create.Code != 200 { t.Fatalf("HTTP %d: %s", create.Code, create.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(create.Body.Bytes(), &envelope) addressID := envelope.Data.ID petID := petWithPhotos(t, a, owner, "球球", 3) task := httptest.NewRecorder() a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":1, "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressID))) if task.Code != 200 { t.Fatalf("HTTP %d: %s", task.Code, task.Body.String()) } var taskEnvelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope) taskID := taskEnvelope.Data.ID if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=?`, sitter, taskID); err != nil { t.Fatal(err) } var detail struct { Address map[string]any `json:"address"` } if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, sitter, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &detail); err != nil { t.Fatal(err) } if detail.Address["emergency"] != "李阿姨 13700000000" || detail.Address["vetHospital"] != "科苑宠物医院" { t.Fatalf("上门的人要能拿到紧急联系人和医院: %+v", detail.Address) } // 任务还在进行时,什么都不该被清掉。 a.scheduleAddressPurge(context.Background()) var purge sql.NullTime if err := db.QueryRow(`SELECT purge_at FROM pet_addresses WHERE id=?`, addressID).Scan(&purge); err != nil { t.Fatal(err) } if purge.Valid { t.Fatal("进行中的任务不该给地址排清除") } if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED' WHERE id=?`, taskID); err != nil { t.Fatal(err) } if scheduled := a.scheduleAddressPurge(context.Background()); scheduled != 1 { t.Fatalf("任务结束后应当排一条清除: %d", scheduled) } if purged := a.purgeDueAddresses(context.Background()); purged != 0 { t.Fatal("还没到期就不该清") } if _, err := db.Exec(`UPDATE pet_addresses SET purge_at=DATE_SUB(NOW(3),INTERVAL 1 HOUR) WHERE id=?`, addressID); err != nil { t.Fatal(err) } if purged := a.purgeDueAddresses(context.Background()); purged != 1 { t.Fatalf("到期应当清除: %d", purged) } var detailCipher []byte var hospital, area string if err := db.QueryRow(`SELECT detail_cipher,vet_hospital,area_text FROM pet_addresses WHERE id=?`, addressID).Scan(&detailCipher, &hospital, &area); err != nil { t.Fatal(err) } if len(detailCipher) != 0 || hospital != "" { t.Fatalf("明文应当被抹掉: detail=%d hospital=%q", len(detailCipher), hospital) } if area != "南山区" { t.Fatalf("街道要留着,历史记录还要显示: %q", area) } // 被清空的地址不能再拿来发新任务,否则喂养者会拿到一个空门牌。 reuse := httptest.NewRecorder() a.createFeedTask(reuse, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-11-01","endDate":"2026-11-01","visitsPerDay":1, "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressID))) if reuse.Code != 404 { t.Fatalf("清空后的地址不该能继续下单: HTTP %d %s", reuse.Code, reuse.Body.String()) } } // 一期的三条服务端约定:定位拿不到也要能存地址、推荐价按物种与城市给、 // 没付款的任务不会永远挂在那里。 func TestPetFeedPhaseOneMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner := int64(1) t.Run("an address saves without coordinates and says so", func(t *testing.T) { w := httptest.NewRecorder() a.createPetAddress(w, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityCode":"440300","cityName":"深圳","areaText":"南山区科技园","detail":"3 栋 1201","contact":"张先生 13800000000"}`)) if w.Code != 200 { t.Fatalf("定位失败不该挡住地址保存: HTTP %d %s", w.Code, w.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` Located bool `json:"located"` } `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &envelope) if envelope.Data.Located { t.Fatal("没有坐标就不能说自己定位过") } var latitude sql.NullFloat64 if err := db.QueryRow(`SELECT latitude FROM pet_addresses WHERE id=?`, envelope.Data.ID).Scan(&latitude); err != nil { t.Fatal(err) } if latitude.Valid { t.Fatal("坐标应当留空,而不是写成 0") } var list struct { Items []map[string]any `json:"items"` } if err := json.Unmarshal(imTestCall(t, a.petAddresses, owner, "GET", "/api/v1/pet/addresses", "", 200), &list); err != nil { t.Fatal(err) } if len(list.Items) != 1 || list.Items[0]["located"] != false || list.Items[0]["cityCode"] != "440300" { t.Fatalf("地址列表要带上城市码与核验状态: %+v", list.Items) } }) t.Run("the suggested price follows species and city", func(t *testing.T) { read := func(query string) map[string]any { t.Helper() var view map[string]any if err := json.Unmarshal(imTestCall(t, a.feedPriceSuggestion, owner, "GET", "/api/v1/pet/feed-price"+query, "", 200), &view); err != nil { t.Fatal(err) } return view } cat := read("?species=cat&cityCode=440300&minMinutes=20") dog := read("?species=dog&cityCode=440300&minMinutes=20") small := read("?species=cat&cityCode=620100&minMinutes=20") longer := read("?species=cat&cityCode=440300&minMinutes=45") if dog["priceCent"].(float64) <= cat["priceCent"].(float64) { t.Fatalf("狗要遛,单价应当高于猫: %v vs %v", dog["priceCent"], cat["priceCent"]) } if small["priceCent"].(float64) >= cat["priceCent"].(float64) { t.Fatalf("没有系数的城市不该比深圳贵: %v vs %v", small["priceCent"], cat["priceCent"]) } if longer["priceCent"].(float64) <= cat["priceCent"].(float64) { t.Fatalf("每次待更久应当更贵: %v", longer["priceCent"]) } // 推荐价必须落在服务端自己会接受的区间里,否则填进去就提交不了。 for _, view := range []map[string]any{cat, dog, small, longer} { price := view["priceCent"].(float64) if price < view["minPriceCent"].(float64) || price > view["maxPriceCent"].(float64) { t.Fatalf("推荐价超出可提交区间: %+v", view) } } // 认识的物种要按自己的档位走,不认识的落到 other。 unknown := read("?species=dragon&cityCode=440300") if unknown["species"] != "other" { t.Fatalf("未知物种应当归到 other: %v", unknown["species"]) } }) t.Run("unpaid tasks are closed after the grace period", func(t *testing.T) { taskID := seedTaskAwaitingPayment(t, a, db, owner) if closed := a.closeStaleUnpaidTasks(context.Background()); closed != 0 { t.Fatalf("刚建的任务不该被关掉: %d", closed) } if _, err := db.Exec(`UPDATE pet_feed_tasks SET created_at=DATE_SUB(NOW(3),INTERVAL 30 HOUR) WHERE id=?`, taskID); err != nil { t.Fatal(err) } if closed := a.closeStaleUnpaidTasks(context.Background()); closed != 1 { t.Fatalf("超过 24 小时未支付应当关闭: %d", closed) } var status string _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status) if status != "CANCELLED" { t.Fatalf("状态 = %s", status) } // 已经付过款的任务不受影响。 paid := seedTaskAwaitingPayment(t, a, db, owner) payFeedTask(t, a, owner, paid) if _, err := db.Exec(`UPDATE pet_feed_tasks SET created_at=DATE_SUB(NOW(3),INTERVAL 30 HOUR) WHERE id=?`, paid); err != nil { t.Fatal(err) } a.closeStaleUnpaidTasks(context.Background()) _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, paid).Scan(&status) if status != "ESCROWED" { t.Fatalf("已托管的任务被误关了: %s", status) } }) } // 三期的三件事:悬赏跟着任务走且不被抽成、退款能分几次退且退不动已结算的钱、 // 管理端能改能删但每一步都留痕。 func TestPetFeedTipAndRefundMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner, sitter := int64(1), int64(2) address := httptest.NewRecorder() a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses", `{"cityCode":"440300","cityName":"深圳","areaText":"南山区","detail":"3 栋 1201","contact":"张先生 13800000000","latitude":22.5411,"longitude":113.9345}`)) var addressEnvelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope) petID := petWithPhotos(t, a, owner, "元宝", 3) var taskID int64 t.Run("the tip rides along and the platform does not take a cut of it", func(t *testing.T) { w := httptest.NewRecorder() a.createFeedTask(w, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-02","visitsPerDay":2, "minMinutes":20,"priceCent":3000,"tipCent":2000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) if w.Code != 200 { t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` GrossCent int64 `json:"grossCent"` FeeCent int64 `json:"platformFeeCent"` PayoutCent int64 `json:"payoutCent"` TipCent int64 `json:"tipCent"` } `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &envelope) taskID = envelope.Data.ID // 4 次 × 30 元 = 120 元基础,抽成 15% 是 18 元,悬赏 20 元原样给喂养者。 if envelope.Data.GrossCent != 14000 || envelope.Data.FeeCent != 1800 || envelope.Data.PayoutCent != 12200 { t.Fatalf("悬赏不该被抽成: %+v", envelope.Data) } if envelope.Data.TipCent != 2000 { t.Fatalf("悬赏没有记下来: %+v", envelope.Data) } over := httptest.NewRecorder() a.createFeedTask(over, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":1, "minMinutes":20,"priceCent":3000,"tipCent":9999999,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) if over.Code != 400 { t.Fatalf("超出上限的悬赏应当被拒绝: HTTP %d", over.Code) } }) t.Run("a refund can be taken in pieces and stops at the total", func(t *testing.T) { payFeedTask(t, a, owner, taskID) thin := httptest.NewRecorder() a.adminRefundFeedTask(thin, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID), `{"reason":"退"}`)) if thin.Code != 400 { t.Fatalf("退款必须写原因: HTTP %d", thin.Code) } part := httptest.NewRecorder() a.adminRefundFeedTask(part, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID), `{"amountCent":4000,"reason":"主人临时改期,先退一次上门的钱"}`)) if part.Code != 200 { t.Fatalf("HTTP %d: %s", part.Code, part.Body.String()) } var status string var refunded int64 if err := db.QueryRow(`SELECT status,refunded_cent FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &refunded); err != nil { t.Fatal(err) } // 部分退款不改状态:服务还要继续。 if status != "ESCROWED" || refunded != 4000 { t.Fatalf("部分退款之后 status=%s refunded=%d", status, refunded) } tooMuch := httptest.NewRecorder() a.adminRefundFeedTask(tooMuch, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID), `{"amountCent":999999,"reason":"想退超过收到的钱"}`)) if tooMuch.Code != 400 { t.Fatalf("不能退超过收到的金额: HTTP %d %s", tooMuch.Code, tooMuch.Body.String()) } rest := httptest.NewRecorder() a.adminRefundFeedTask(rest, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID), `{"reason":"主人取消行程,余下全退"}`)) if rest.Code != 200 { t.Fatalf("HTTP %d: %s", rest.Code, rest.Body.String()) } if err := db.QueryRow(`SELECT status,refunded_cent FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &refunded); err != nil { t.Fatal(err) } if status != "REFUNDED" || refunded != 14000 { t.Fatalf("退完之后 status=%s refunded=%d", status, refunded) } var orderRefunded int64 if err := db.QueryRow(`SELECT refunded_cent FROM orders WHERE product_type='pet_feed' AND product_id=?`, taskID).Scan(&orderRefunded); err != nil { t.Fatal(err) } if orderRefunded != 14000 { t.Fatalf("订单上的已退金额对不上: %d", orderRefunded) } var rows int if err := db.QueryRow(`SELECT COUNT(*) FROM pet_feed_refunds WHERE task_id=?`, taskID).Scan(&rows); err != nil { t.Fatal(err) } if rows != 2 { t.Fatalf("两次退款应当各留一行: %d", rows) } done := httptest.NewRecorder() a.adminRefundFeedTask(done, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID), `{"reason":"再退一次看看会不会重复退"}`)) if done.Code != 400 { t.Fatalf("退完之后不该还能退: HTTP %d", done.Code) } }) t.Run("a settled task cannot be refunded by accident", func(t *testing.T) { settled := seedAssignedTask(t, a, db, owner, sitter) if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED',order_id=NULL WHERE id=?`, settled); err != nil { t.Fatal(err) } careless := httptest.NewRecorder() a.adminRefundFeedTask(careless, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", settled), `{"reason":"主人事后反悔要退款"}`)) if careless.Code != 400 || !strings.Contains(careless.Body.String(), "平台承担") { t.Fatalf("已结算的任务不该顺手退掉: HTTP %d %s", careless.Code, careless.Body.String()) } deliberate := httptest.NewRecorder() a.adminRefundFeedTask(deliberate, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", settled), `{"platformBears":true,"amountCent":3000,"reason":"服务确有问题,这笔由平台补给主人"}`)) if deliberate.Code != 200 { t.Fatalf("HTTP %d: %s", deliberate.Code, deliberate.Body.String()) } var status string var bears bool if err := db.QueryRow(`SELECT t.status,f.platform_bears FROM pet_feed_tasks t JOIN pet_feed_refunds f ON f.task_id=t.id WHERE t.id=?`, settled).Scan(&status, &bears); err != nil { t.Fatal(err) } if status != "SETTLED" || !bears { t.Fatalf("平台承担的退款不该改任务状态: status=%s bears=%v", status, bears) } // 喂养者已经拿到的钱不能被这条退款倒扣回去。 var balance int64 _ = db.QueryRow(`SELECT COALESCE(available_cent,0) FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&balance) if balance < 0 { t.Fatalf("喂养者余额被倒扣了: %d", balance) } }) } func TestPetAdminCrudMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} a.config.ConfigEncryptionKey = strings.Repeat("k", 32) owner := int64(1) var petID int64 t.Run("an operator can create a profile on someone else's behalf", func(t *testing.T) { bad := httptest.NewRecorder() a.adminCreatePet(bad, imTestRequest(1, "POST", "/admin/v1/pets", `{"ownerUserId":1,"name":"测试","species":"cat","birthday":"2026-13-45"}`)) if bad.Code != 400 { t.Fatalf("日期不合法应当被拒: HTTP %d %s", bad.Code, bad.Body.String()) } w := httptest.NewRecorder() a.adminCreatePet(w, imTestRequest(1, "POST", "/admin/v1/pets", `{"ownerUserId":1,"name":"客服建的","species":"cat","breed":"狸花","gender":2,"birthday":"2023-01-01", "weightG":4000,"vaccinatedAt":"2026-06-01","photos":["https://cdn.example.com/a.jpg","https://cdn.example.com/b.jpg"]}`)) if w.Code != 200 { t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) } var envelope struct { Data struct { ID int64 `json:"id"` } `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &envelope) petID = envelope.Data.ID var name, avatar string var photos int if err := db.QueryRow(`SELECT p.name,p.avatar_url,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id) FROM pets p WHERE p.id=?`, petID).Scan(&name, &avatar, &photos); err != nil { t.Fatal(err) } if name != "客服建的" || photos != 2 || avatar == "" { t.Fatalf("name=%s photos=%d avatar=%q", name, photos, avatar) } var logged int if err := db.QueryRow(`SELECT COUNT(*) FROM admin_audit_logs WHERE action='create' AND target_type='pet' AND target_id=?`, petID).Scan(&logged); err != nil { t.Fatal(err) } if logged != 1 { t.Fatalf("代建档案要留审计: %d", logged) } }) t.Run("editing replaces the photo set and tells the owner", func(t *testing.T) { w := httptest.NewRecorder() a.adminUpdatePet(w, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pets/%d", petID), `{"name":"改过的名字","species":"cat","breed":"英短","gender":2,"weightG":4500, "vaccinatedAt":"2026-07-01","photos":["https://cdn.example.com/c.jpg"]}`)) if w.Code != 200 { t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) } var name string var photos int if err := db.QueryRow(`SELECT p.name,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id) FROM pets p WHERE p.id=?`, petID). Scan(&name, &photos); err != nil { t.Fatal(err) } if name != "改过的名字" || photos != 1 { t.Fatalf("name=%s photos=%d", name, photos) } var notified int if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE user_id=? AND biz_type='pet'`, owner).Scan(¬ified); err != nil { t.Fatal(err) } if notified == 0 { t.Fatal("后台改了用户的档案,用户应当收到通知") } }) t.Run("a listing can be corrected and taken down", func(t *testing.T) { listed := petWithPhotos(t, a, owner, "待送养", 3) publish := httptest.NewRecorder() a.createAdoption(publish, imTestRequest(owner, "POST", "/api/v1/pet/adoptions", fmt.Sprintf(`{"petId":%d,"cityCode":"440300","cityName":"深圳","reason":"工作变动实在照顾不了","requirements":"希望定期反馈"}`, listed))) if publish.Code != 200 { t.Fatalf("HTTP %d: %s", publish.Code, publish.Body.String()) } var adoptionID int64 if err := db.QueryRow(`SELECT id FROM pet_adoptions WHERE pet_id=?`, listed).Scan(&adoptionID); err != nil { t.Fatal(err) } edit := httptest.NewRecorder() a.adminUpdatePetListing(edit, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/listings/%d", adoptionID), `{"kind":"adoption","body":"客服代为整理过的送养说明","cityName":"深圳市"}`)) if edit.Code != 200 { t.Fatalf("HTTP %d: %s", edit.Code, edit.Body.String()) } var reason, city string if err := db.QueryRow(`SELECT reason,city_name FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&reason, &city); err != nil { t.Fatal(err) } if reason != "客服代为整理过的送养说明" || city != "深圳市" { t.Fatalf("reason=%s city=%s", reason, city) } remove := httptest.NewRecorder() a.adminDeletePetListing(remove, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pet/listings/%d?kind=adoption", adoptionID), ``)) if remove.Code != 200 { t.Fatalf("HTTP %d: %s", remove.Code, remove.Body.String()) } var deleted sql.NullTime if err := db.QueryRow(`SELECT deleted_at FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&deleted); err != nil { t.Fatal(err) } if !deleted.Valid { t.Fatal("删除应当是软删除,记录还要留着") } }) t.Run("a pet with a live task cannot be deleted", func(t *testing.T) { busy := seedTaskAwaitingPayment(t, a, db, owner) var busyPet int64 if err := db.QueryRow(`SELECT pet_id FROM pet_feed_tasks WHERE id=?`, busy).Scan(&busyPet); err != nil { t.Fatal(err) } w := httptest.NewRecorder() a.adminDeletePet(w, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pets/%d", busyPet), ``)) if w.Code != 400 { t.Fatalf("有进行中任务的宠物不该能删: HTTP %d %s", w.Code, w.Body.String()) } // 任务结束之后就可以删了,送养信息跟着下架。 if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='CANCELLED' WHERE id=?`, busy); err != nil { t.Fatal(err) } ok := httptest.NewRecorder() a.adminDeletePet(ok, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pets/%d", busyPet), ``)) if ok.Code != 200 { t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String()) } }) t.Run("cancelling a task needs a reason and refuses to strand money", func(t *testing.T) { funded := seedTaskAwaitingPayment(t, a, db, owner) payFeedTask(t, a, owner, funded) silent := httptest.NewRecorder() a.adminUpdateFeedTask(silent, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d", funded), `{"cancel":true}`)) if silent.Code != 400 { t.Fatalf("取消要写原因: HTTP %d", silent.Code) } holding := httptest.NewRecorder() a.adminUpdateFeedTask(holding, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d", funded), `{"cancel":true,"reason":"主人来电取消"}`)) if holding.Code != 400 || !strings.Contains(holding.Body.String(), "退给主人") { t.Fatalf("还托管着钱就取消,应当先要求退款: HTTP %d %s", holding.Code, holding.Body.String()) } refund := httptest.NewRecorder() a.adminRefundFeedTask(refund, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", funded), `{"reason":"主人来电取消,全额退回"}`)) if refund.Code != 200 { t.Fatalf("HTTP %d: %s", refund.Code, refund.Body.String()) } var status string _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, funded).Scan(&status) if status != "REFUNDED" { t.Fatalf("全额退款后状态 = %s", status) } }) }