领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工, 送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。 代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定 之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认 后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次; 申诉会冻结自动确认,由客服裁定全付、部分付或全退。 提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
430 lines
18 KiB
Go
430 lines
18 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// 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 without a location is refused", 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 != 400 {
|
|
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.
|
|
pay := httptest.NewRecorder()
|
|
a.escrowFeedTask(pay, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/pay", taskID), ``))
|
|
if pay.Code != 200 {
|
|
t.Fatalf("支付失败: %s", pay.Body.String())
|
|
}
|
|
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) {
|
|
for _, 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}`, step, step)))
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|