退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按 金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装 调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能 由平台承担,必须显式勾选。 后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除 (有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务 能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。 悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者, 平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1327 lines
56 KiB
Go
1327 lines
56 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|