Files
pay/internal/handler/refund_test.go
T

133 lines
5.5 KiB
Go

package handler_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/provider/fake"
"github.com/wangjia/pay/internal/router"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/util"
)
const refundSecret = "s3cr3t-pangolin"
func jsonBody(b []byte) *bytes.Reader { return bytes.NewReader(b) }
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
func serve(t *testing.T, r *gin.Engine, req *http.Request) *http.Response {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w.Result()
}
func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.OrderStore, *fake.Provider) {
t.Helper()
gin.SetMode(gin.TestMode)
config.C = config.Config{Biz: map[string]config.BizSystemConfig{
"pangolin": {Secret: refundSecret, CallbackURL: "http://x"},
}}
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}})
g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global", subs)
r := gin.New()
router.SetupV2(r, g)
return r, g, orders, fp
}
// 用 pangolin secret 给退款请求签名(与 verifyBizSign 一致)。
func signedRefundReq(t *testing.T, r *gin.Engine, body map[string]any) *http.Response {
t.Helper()
raw, _ := json.Marshal(body)
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.NewString()
sign := util.HMACSign(refundSecret, "pangolin", ts, nonce, string(raw))
req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pay-System", "pangolin")
req.Header.Set("X-Pay-Timestamp", ts)
req.Header.Set("X-Pay-Nonce", nonce)
req.Header.Set("X-Pay-Sign", sign)
return serve(t, r, req)
}
func TestRefundEndpointE2E(t *testing.T) {
r, g, orders, fp := buildRefundEngine(t)
fp.EnableRefund("fake-ref", provider.PaidSucceeded, nil)
// 下单 + 付
res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"})
_, _ = g.SyncPendingAttempts(context.Background(), 10)
// 签名退款 → 200 succeeded
resp := signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 29990000, "biz_system": "pangolin", "reason": "test"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("refund status = %d want 200", resp.StatusCode)
}
if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 {
t.Fatalf("order = %s want refunded", o.Status)
}
// 未签名 → 401
req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(mustJSON(map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 1, "biz_system": "pangolin"})))
req.Header.Set("Content-Type", "application/json")
if resp := serve(t, r, req); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unsigned status = %d want 401", resp.StatusCode)
}
}
func TestManualRefundAdminFlow(t *testing.T) {
t.Setenv("PAY_ADMIN_TOKEN", "adm-tok")
r, g, orders, fp := buildRefundEngine(t)
// fp 不 EnableRefund → manual_pending
res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-2"})
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"})
_, _ = g.SyncPendingAttempts(context.Background(), 10)
mr, _ := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 29990000, BizSystem: "pangolin"})
// admin 无 token → 403
req, _ := http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil)
if resp := serve(t, r, req); resp.StatusCode != http.StatusForbidden {
t.Fatalf("no-token status = %d want 403", resp.StatusCode)
}
// admin 带 token → 200 且列出待办
req, _ = http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil)
req.Header.Set("X-Pay-Admin", "adm-tok")
if resp := serve(t, r, req); resp.StatusCode != http.StatusOK {
t.Fatalf("admin list status = %d want 200", resp.StatusCode)
}
// complete → order refunded
req, _ = http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx"})))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pay-Admin", "adm-tok")
if resp := serve(t, r, req); resp.StatusCode != http.StatusOK {
t.Fatalf("complete status = %d want 200", resp.StatusCode)
}
if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 {
t.Fatalf("order = %s want refunded", o.Status)
}
}