package handler import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/wechatpay-apiv3/wechatpay-go/core" "github.com/wechatpay-apiv3/wechatpay-go/services/payments" "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" "past/internal/config" "past/internal/httpapi" "past/internal/model" "past/internal/store" "past/internal/wxpay" ) type paymentFake struct { prepayErr error query *payments.Transaction notify *payments.Transaction notifyErr error last wxpay.PrepayOrder } func (f *paymentFake) Prepay(_ context.Context, o wxpay.PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error) { f.last = o if f.prepayErr != nil { return nil, f.prepayErr } return &jsapi.PrepayWithRequestPaymentResponse{PrepayId: core.String("prepay-test"), TimeStamp: core.String("123456"), NonceStr: core.String("nonce"), Package: core.String("prepay_id=prepay-test"), SignType: core.String("RSA"), PaySign: core.String("signature")}, nil } func (f *paymentFake) Query(context.Context, string) (*payments.Transaction, error) { return f.query, nil } func (f *paymentFake) ParseNotify(context.Context, *http.Request) (*payments.Transaction, error) { return f.notify, f.notifyErr } func exerciseWechatPayment(t *testing.T, db *store.DB, app *httpapi.App, engine http.Handler, request func(string, string, string, M, int) M, covered map[string]int, happy map[string]bool, uid, token, otherToken string) { t.Helper() oldCfg := app.Cfg.WechatPay defer func() { app.Cfg.WechatPay = oldCfg; app.WechatPay = nil }() app.Cfg.WechatPay = config.WechatPayConfig{Enabled: true, AppID: "wx-pay-test", MchID: "merchant-test"} gateway := &paymentFake{} app.WechatPay = gateway if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Updates(map[string]any{"openid": "pay-openid", "storage_vip_expire_at": time.Now().AddDate(0, -3, 0)}).Error; err != nil { t.Fatal(err) } if err := db.ORM.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil { t.Fatal(err) } create := func(plan string) model.StorageOrder { action := "storage_create_order" if plan == "year" { action = "storage_order_create" } r := request(action, "POST", token, M{"plan_key": plan, "amount_cents": 1}, 200) if r["pay_provider"] != "wechat" || r["payment"].(M)["package"] != "prepay_id=prepay-test" { t.Fatalf("bad payment params: %+v", r) } var o model.StorageOrder if err := db.ORM.Where("out_trade_no=?", r["out_trade_no"]).First(&o).Error; err != nil { t.Fatal(err) } if o.AmountCents != gateway.last.Amount || o.AmountCents <= 1 || o.PrepayId != "prepay-test" || o.PayerOpenId != "pay-openid" || o.PayAppId != "wx-pay-test" || o.PayMchId != "merchant-test" || o.PayProvider != "wechat" { t.Fatalf("incorrect payment snapshot: %+v", o) } return o } order := create("month") transaction := func(o model.StorageOrder) *payments.Transaction { return &payments.Transaction{Appid: core.String(o.PayAppId), Mchid: core.String(o.PayMchId), OutTradeNo: core.String(o.OutTradeNo), TransactionId: core.String("wx-" + o.OutTradeNo), TradeState: core.String("SUCCESS"), TradeType: core.String("JSAPI"), SuccessTime: core.String(time.Now().Format(time.RFC3339)), Amount: &payments.TransactionAmount{Total: core.Int64(o.AmountCents), Currency: core.String("CNY")}, Payer: &payments.TransactionPayer{Openid: core.String(o.PayerOpenId)}} } request("storage_order_status", "GET", otherToken, M{"out_trade_no": order.OutTradeNo}, 404) for _, field := range []string{"amount", "currency", "appid", "merchant", "payer", "state", "trade_type", "time", "nil_amount", "transaction"} { tx := transaction(order) switch field { case "amount": tx.Amount.Total = core.Int64(1) case "currency": tx.Amount.Currency = core.String("USD") case "appid": tx.Appid = core.String("foreign-app") case "merchant": tx.Mchid = core.String("foreign-merchant") case "payer": tx.Payer.Openid = core.String("foreign-payer") case "state": tx.TradeState = core.String("NOTPAY") case "trade_type": tx.TradeType = core.String("NATIVE") case "time": tx.SuccessTime = nil case "nil_amount": tx.Amount = nil case "transaction": tx.TransactionId = nil } if err := deliverWechatStorageOrder(db, tx); err == nil { t.Fatalf("invalid %s accepted", field) } } callback := func(want int) { r := httptest.NewRequest("POST", "/api/wechatPayNotify", strings.NewReader(`{}`)) r.Header.Set("Content-Type", "application/json") out := httptest.NewRecorder() engine.ServeHTTP(out, r) covered["wechat_pay_notify"]++ if out.Code != want { t.Fatalf("wechat callback %d want %d: %s", out.Code, want, out.Body.String()) } if out.Code == 204 { happy["wechat_pay_notify"] = true } } gateway.notifyErr = errors.New("bad signature") callback(400) gateway.notifyErr = nil gateway.notify = transaction(order) gateway.notify.Amount.Total = core.Int64(1) callback(500) var unpaid model.StorageOrder db.ORM.First(&unpaid, "id=?", order.Id) if unpaid.Status != "pending" { t.Fatal("invalid notify changed status") } gateway.query = transaction(order) gateway.query.TradeState = core.String("NOTPAY") request("storage_order_status", "GET", token, M{"out_trade_no": order.OutTradeNo}, 200) gateway.notify = transaction(order) // Simultaneous authentic notifications are fulfilled exactly once. results := make(chan error, 6) var wg sync.WaitGroup for i := 0; i < 6; i++ { wg.Add(1) go func() { defer wg.Done(); results <- deliverWechatStorageOrder(db, transaction(order)) }() } wg.Wait() close(results) for err := range results { if err != nil { t.Fatal(err) } } callback(204) callback(204) var user model.User db.ORM.First(&user, "id=?", uid) target := time.Now().AddDate(0, 1, 0) if user.StorageVipExpireAt == nil || user.StorageVipExpireAt.Before(target.Add(-time.Minute)) || user.StorageVipExpireAt.After(target.Add(time.Minute)) || user.StoragePlan != "month" { t.Fatalf("expired VIP renewal or idempotency failed: %+v", user.StorageVipExpireAt) } before := *user.StorageVipExpireAt // Active query recovers a lost callback; requesting status again cannot extend VIP. annual := create("year") gateway.query = transaction(annual) r := request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200) if r["status"] != "success" { t.Fatalf("query did not deliver: %+v", r) } request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200) db.ORM.First(&user, "id=?", uid) if !user.StorageVipExpireAt.Equal(before.AddDate(1, 0, 0)) { t.Fatalf("annual membership wrong: %v", user.StorageVipExpireAt) } // A successful WeChat transaction cannot be applied to another order. duplicate := create("month") same := transaction(duplicate) same.TransactionId = transaction(order).TransactionId if err := deliverWechatStorageOrder(db, same); err == nil { t.Fatal("duplicate WeChat transaction accepted") } gateway.query = transaction(duplicate) gateway.query.TradeState = core.String("CLOSED") r = request("storage_order_status", "GET", token, M{"out_trade_no": duplicate.OutTradeNo}, 200) if r["status"] != "closed" { t.Fatal("closed order not recorded") } // Transactional rollback if the owner has been deleted. vanished := create("month") var beforeDeleted model.User db.ORM.First(&beforeDeleted, "id=?", uid) if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } if err := deliverWechatStorageOrder(db, transaction(vanished)); err == nil { t.Fatal("deleted owner granted membership") } db.ORM.Unscoped().Model(&model.User{}).Where("id=?", uid).Update("deleted_at", nil) var rolledBack model.StorageOrder db.ORM.First(&rolledBack, "id=?", vanished.Id) if rolledBack.Status != "pending" { t.Fatal("failed grant marked paid") } // Search by order number, UUID, and public ID must all stay valid SQL. var owner model.User db.ORM.First(&owner, "id=?", uid) for _, term := range []string{order.OutTradeNo, uid, owner.UserId} { request("admin_storage_orders", "GET", "admin", M{"search": term}, 200) } t.Log(fmt.Sprintf("WeChat payment snapshots, validation, replay safety and query recovery verified")) }