da8bbefe2e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u # Conflicts: # internal/model/testdb.go # internal/provider/provider.go # internal/router/router.go # internal/store/order_query_test.go # main.go
263 lines
11 KiB
Go
263 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|