Files
past-go/past-go/internal/handler/wechat_pay_test.go
T
Blizzard 4c9708b562 feat: add WeChat Pay, Redis logging, storage tiers and personal albums
Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
2026-11-02 23:40:49 +08:00

211 lines
8.2 KiB
Go

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