package handler_test import ( "bytes" "context" "encoding/json" "io" "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) chargebacks := store.NewChargebackStore(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, chargebacks) 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) } } // TestRefundHTTPErrorMapping table-drives writeRefundErr(POST /refunds)与 // CompleteRefund(POST /admin/refunds/:id/complete)的错误 → {status,code} 映射, // 断言 P4 final-review item 3 点名的四种场景。 func TestRefundHTTPErrorMapping(t *testing.T) { payOrder := func(t *testing.T, g *gateway.Gateway, orders *store.OrderStore, fp *fake.Provider, orderNo string) { t.Helper() atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) var ref string for _, a := range atts { if a.OutTradeNo == orderNo { ref = a.ProviderRef } } fp.SetQueryResult(ref, provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}) if _, err := g.SyncPendingAttempts(context.Background(), 10); err != nil { t.Fatalf("sync: %v", err) } } cases := []struct { name string run func(t *testing.T) *http.Response wantStatus int wantCode string }{ { name: "未知订单 → 404 order_not_found", run: func(t *testing.T) *http.Response { r, _, _, _ := buildRefundEngine(t) return signedRefundReq(t, r, map[string]any{"out_trade_no": "no-such-order", "amount_minor": 100, "biz_system": "pangolin"}) }, wantStatus: http.StatusNotFound, wantCode: "order_not_found", }, { // item 4:GetRefund 对 store.ErrRefundNotFound 精确映射 404 refund_not_found // (而非把所有查询失败都笼统 404)。 name: "未知退款单号(GET /refunds/:id)→ 404 refund_not_found", run: func(t *testing.T) *http.Response { r, _, _, _ := buildRefundEngine(t) req, _ := http.NewRequest(http.MethodGet, "/api/v2/refunds/no-such-refund-id", nil) return serve(t, r, req) }, wantStatus: http.StatusNotFound, wantCode: "refund_not_found", }, { name: "订单未结算(仍 pending)→ 409 order_not_refundable", run: func(t *testing.T) *http.Response { r, g, _, _ := buildRefundEngine(t) res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-pending"}) if err != nil { t.Fatalf("create: %v", err) } return signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 100, "biz_system": "pangolin"}) }, wantStatus: http.StatusConflict, wantCode: "order_not_refundable", }, { name: "超退金额(超过可退余额)→ 422 refund_amount_invalid", run: func(t *testing.T) *http.Response { r, g, orders, fp := buildRefundEngine(t) res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-over"}) if err != nil { t.Fatalf("create: %v", err) } payOrder(t, g, orders, fp, res.OrderNo) return signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 29990000 + 1, "biz_system": "pangolin"}) }, wantStatus: http.StatusUnprocessableEntity, wantCode: "refund_amount_invalid", }, { name: "重复人工完成(已非 manual_pending)→ 409 not_manual", run: func(t *testing.T) *http.Response { t.Setenv("PAY_ADMIN_TOKEN", "adm-tok") r, g, orders, fp := buildRefundEngine(t) res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-dup"}) if err != nil { t.Fatalf("create: %v", err) } payOrder(t, g, orders, fp, res.OrderNo) // fp 未 EnableRefund → manual_pending mr, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 29990000, BizSystem: "pangolin"}) if err != nil { t.Fatalf("refund: %v", err) } 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("first complete status = %d want 200", resp.StatusCode) } // 第二次 complete:refund 已 succeeded,非 manual_pending → 应 409。 req2, _ := http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx-2"}))) req2.Header.Set("Content-Type", "application/json") req2.Header.Set("X-Pay-Admin", "adm-tok") return serve(t, r, req2) }, wantStatus: http.StatusConflict, wantCode: "not_manual", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { resp := c.run(t) body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } if resp.StatusCode != c.wantStatus { t.Fatalf("status = %d want %d, body=%s", resp.StatusCode, c.wantStatus, body) } var out struct { Code string `json:"code"` } if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("decode body %s: %v", body, err) } if out.Code != c.wantCode { t.Fatalf("code = %q want %q, body=%s", out.Code, c.wantCode, body) } }) } }