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