312 lines
12 KiB
Go
312 lines
12 KiB
Go
package payment
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"kra/app/system/internal/biz"
|
|
|
|
"github.com/go-pay/gopay"
|
|
"github.com/go-pay/gopay/saobei"
|
|
)
|
|
|
|
type saobeiClientStub struct {
|
|
miniPay func(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error)
|
|
barcodePay func(context.Context, gopay.BodyMap) (*saobei.BarcodePayRsp, error)
|
|
query func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error)
|
|
refund func(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error)
|
|
}
|
|
|
|
func (s *saobeiClientStub) MiniPay(ctx context.Context, bm gopay.BodyMap) (*saobei.MiniPayRsp, error) {
|
|
return s.miniPay(ctx, bm)
|
|
}
|
|
|
|
func (s *saobeiClientStub) BarcodePay(ctx context.Context, bm gopay.BodyMap) (*saobei.BarcodePayRsp, error) {
|
|
return s.barcodePay(ctx, bm)
|
|
}
|
|
|
|
func (s *saobeiClientStub) Query(ctx context.Context, bm gopay.BodyMap) (*saobei.QueryRsp, error) {
|
|
return s.query(ctx, bm)
|
|
}
|
|
|
|
func (s *saobeiClientStub) Refund(ctx context.Context, bm gopay.BodyMap) (*saobei.RefundRsp, error) {
|
|
return s.refund(ctx, bm)
|
|
}
|
|
|
|
func newSaobeiTestAdapter(client saobeiClient) *saobeiAdapter {
|
|
return &saobeiAdapter{newClient: func(map[string]any) (saobeiClient, error) {
|
|
return client, nil
|
|
}}
|
|
}
|
|
|
|
func TestSaobeiOKRequiresExplicitSuccess(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
returnCode string
|
|
resultCode string
|
|
want bool
|
|
}{
|
|
{name: "documented success", returnCode: "01", resultCode: "01", want: true},
|
|
{name: "text success", returnCode: "SUCCESS", resultCode: "SUCCESS", want: true},
|
|
{name: "missing result code", returnCode: "01", want: false},
|
|
{name: "empty response"},
|
|
{name: "business failure", returnCode: "01", resultCode: "02"},
|
|
{name: "communication failure", returnCode: "02", resultCode: "01"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := saobeiOK(tc.returnCode, tc.resultCode); got != tc.want {
|
|
t.Fatalf("saobeiOK(%q, %q) = %v, want %v", tc.returnCode, tc.resultCode, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSaobeiCreateKeepsQueryAndProviderTradeIDsSeparate(t *testing.T) {
|
|
t.Run("mini pay", func(t *testing.T) {
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{miniPay: func(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error) {
|
|
return &saobei.MiniPayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, OutTradeNo: "SAOBEI-ORDER-1"}, nil
|
|
}})
|
|
result, err := adapter.Create(context.Background(), &biz.PaymentRequest{
|
|
TradeNo: "LOCAL-1",
|
|
Amount: 100,
|
|
Extra: map[string]any{"sub_appid": "APP-1", "open_id": "OPEN-1"},
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
if result.TradeNo != "LOCAL-1" || result.QueryID != "SAOBEI-ORDER-1" || result.ProviderTradeNo != "" {
|
|
t.Fatalf("Create() result = %+v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("barcode pay", func(t *testing.T) {
|
|
var request gopay.BodyMap
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{barcodePay: func(_ context.Context, bm gopay.BodyMap) (*saobei.BarcodePayRsp, error) {
|
|
request = bm
|
|
return &saobei.BarcodePayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, OutTradeNo: "SAOBEI-ORDER-2", ChannelTradeNo: "CHANNEL-2"}, nil
|
|
}})
|
|
result, err := adapter.Create(context.Background(), &biz.PaymentRequest{
|
|
TradeNo: "LOCAL-2",
|
|
Amount: 200,
|
|
Extra: map[string]any{"method": "barcode", "auth_code": "AUTH-2"},
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
if result.TradeNo != "LOCAL-2" || result.QueryID != "SAOBEI-ORDER-2" || result.ProviderTradeNo != "" {
|
|
t.Fatalf("Create() result = %+v", result)
|
|
}
|
|
if request.GetString("auth_no") != "AUTH-2" {
|
|
t.Fatalf("Create() auth_no = %q", request.GetString("auth_no"))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSaobeiCreateRejectsMissingPlatformOrderID(t *testing.T) {
|
|
for _, method := range []string{"", "barcode"} {
|
|
t.Run(method, func(t *testing.T) {
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{
|
|
miniPay: func(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error) {
|
|
return &saobei.MiniPayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}}, nil
|
|
},
|
|
barcodePay: func(context.Context, gopay.BodyMap) (*saobei.BarcodePayRsp, error) {
|
|
return &saobei.BarcodePayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}}, nil
|
|
},
|
|
})
|
|
extra := map[string]any{}
|
|
if method != "" {
|
|
extra["method"] = method
|
|
}
|
|
if _, err := adapter.Create(context.Background(), &biz.PaymentRequest{TradeNo: "LOCAL-MISSING", Amount: 1, Extra: extra}, nil); err == nil {
|
|
t.Fatal("Create() accepted response without out_trade_no")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSaobeiCreateRejectsUnknownMethodBeforeClient(t *testing.T) {
|
|
clientCreated := false
|
|
adapter := &saobeiAdapter{newClient: func(map[string]any) (saobeiClient, error) {
|
|
clientCreated = true
|
|
return &saobeiClientStub{}, nil
|
|
}}
|
|
|
|
_, err := adapter.Create(context.Background(), &biz.PaymentRequest{
|
|
TradeNo: "LOCAL-UNKNOWN-METHOD", Amount: 1, Extra: map[string]any{"method": "unsupported"},
|
|
}, nil)
|
|
if err == nil {
|
|
t.Fatal("Create() accepted an unknown method")
|
|
}
|
|
if clientCreated {
|
|
t.Fatal("Create() initialized the GoPay client before rejecting the unknown method")
|
|
}
|
|
}
|
|
|
|
func TestSaobeiQueryRestoresMerchantTradeNoAndKeepsQueryID(t *testing.T) {
|
|
var queryBody gopay.BodyMap
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(_ context.Context, bm gopay.BodyMap) (*saobei.QueryRsp, error) {
|
|
queryBody = bm
|
|
return &saobei.QueryRsp{
|
|
RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"},
|
|
PayTrace: "LOCAL-1",
|
|
OutTradeNo: "SAOBEI-ORDER-1",
|
|
ChannelTradeNo: "CHANNEL-1",
|
|
TradeState: "SUCCESS",
|
|
TotalFee: "100",
|
|
BuyerPayFee: "90",
|
|
}, nil
|
|
}})
|
|
|
|
result, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", map[string]any{"currency": "cny"})
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
if queryBody.GetString("out_trade_no") != "SAOBEI-ORDER-1" {
|
|
t.Fatalf("Query() out_trade_no = %q", queryBody.GetString("out_trade_no"))
|
|
}
|
|
if trace := queryBody.GetString("terminal_trace"); trace == "" || trace == "SAOBEI-ORDER-1" || len(trace) > 32 {
|
|
t.Fatalf("Query() terminal_trace = %q, want an independent query流水号", trace)
|
|
}
|
|
if result.TradeNo != "LOCAL-1" || result.QueryID != "SAOBEI-ORDER-1" || result.ProviderTradeNo != "CHANNEL-1" {
|
|
t.Fatalf("Query() result = %+v", result)
|
|
}
|
|
if result.Status != "success" || result.Amount != 100 || result.PayerPaidAmount != 90 || result.Currency != "CNY" {
|
|
t.Fatalf("Query() payment fields = %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestSaobeiQueryRejectsMissingPaymentTrace(t *testing.T) {
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) {
|
|
return &saobei.QueryRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, OutTradeNo: "SAOBEI-ORDER-1", TradeState: "NOTPAY"}, nil
|
|
}})
|
|
|
|
if _, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", nil); err == nil {
|
|
t.Fatal("Query() accepted a response without pay_trace")
|
|
}
|
|
}
|
|
|
|
func TestSaobeiQueryRejectsInvalidTotalFee(t *testing.T) {
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) {
|
|
return &saobei.QueryRsp{
|
|
RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"},
|
|
PayTrace: "LOCAL-1",
|
|
OutTradeNo: "SAOBEI-ORDER-1",
|
|
TradeState: "SUCCESS",
|
|
TotalFee: "invalid",
|
|
BuyerPayFee: "90",
|
|
}, nil
|
|
}})
|
|
|
|
if _, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", nil); err == nil {
|
|
t.Fatal("Query() accepted an invalid total_fee")
|
|
}
|
|
}
|
|
|
|
func TestSaobeiQueryTreatsPayingAsPending(t *testing.T) {
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) {
|
|
return &saobei.QueryRsp{
|
|
RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: saobei.ResultCodePaying},
|
|
TerminalTrace: "LOCAL-QUERY-1",
|
|
OutTradeNo: "SAOBEI-ORDER-1",
|
|
TradeState: "SUCCESS",
|
|
TotalFee: "50",
|
|
ChannelTradeNo: "CHANNEL-1",
|
|
}, nil
|
|
}})
|
|
|
|
result, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", nil)
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
if result.Status != "pending" {
|
|
t.Fatalf("Query() status = %q, want pending", result.Status)
|
|
}
|
|
}
|
|
|
|
func TestSaobeiRefundPrefersPersistedQueryID(t *testing.T) {
|
|
var refundBody gopay.BodyMap
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(_ context.Context, bm gopay.BodyMap) (*saobei.RefundRsp, error) {
|
|
refundBody = bm
|
|
return &saobei.RefundRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, TerminalTrace: "LOCAL-REFUND-1", OutTradeNo: "SAOBEI-ORDER-1", OutRefundNo: "SAOBEI-REFUND-1", RefundFee: "50"}, nil
|
|
}})
|
|
|
|
result, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{
|
|
TradeNo: "LOCAL-1", QueryID: "SAOBEI-ORDER-1", ProviderTradeNo: "CHANNEL-1",
|
|
RefundNo: "LOCAL-REFUND-1", Amount: 50, Currency: "cny",
|
|
}, map[string]any{"refund_extra": map[string]any{"out_refund_no": "MUST-NOT-BE-SENT"}})
|
|
if err != nil {
|
|
t.Fatalf("Refund() error = %v", err)
|
|
}
|
|
if refundBody.GetString("out_trade_no") != "SAOBEI-ORDER-1" {
|
|
t.Fatalf("Refund() out_trade_no = %q, want persisted QueryID", refundBody.GetString("out_trade_no"))
|
|
}
|
|
if refundBody.GetString("terminal_trace") != "LOCAL-REFUND-1" {
|
|
t.Fatalf("Refund() terminal_trace = %q, want refund number", refundBody.GetString("terminal_trace"))
|
|
}
|
|
if result.TradeNo != "LOCAL-1" || result.ProviderTradeNo != "SAOBEI-REFUND-1" || result.Amount != 50 || result.Currency != "CNY" {
|
|
t.Fatalf("Refund() result = %+v", result)
|
|
}
|
|
if _, ok := refundBody["out_refund_no"]; ok {
|
|
t.Fatal("Refund() sent unsupported out_refund_no request field")
|
|
}
|
|
}
|
|
|
|
func TestSaobeiRefundFallsBackToMerchantTradeNo(t *testing.T) {
|
|
var refundBody gopay.BodyMap
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(_ context.Context, bm gopay.BodyMap) (*saobei.RefundRsp, error) {
|
|
refundBody = bm
|
|
return &saobei.RefundRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, TerminalTrace: "LOCAL-REFUND-2", OutRefundNo: "SAOBEI-REFUND-2", RefundFee: "25"}, nil
|
|
}})
|
|
|
|
_, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{
|
|
TradeNo: "LOCAL-2", RefundNo: "LOCAL-REFUND-2", Amount: 25, Currency: "CNY",
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Refund() error = %v", err)
|
|
}
|
|
if refundBody.GetString("out_trade_no") != "LOCAL-2" {
|
|
t.Fatalf("Refund() out_trade_no = %q, want merchant trade number fallback", refundBody.GetString("out_trade_no"))
|
|
}
|
|
if refundBody.GetString("terminal_trace") != "LOCAL-REFUND-2" {
|
|
t.Fatalf("Refund() terminal_trace = %q, want refund number", refundBody.GetString("terminal_trace"))
|
|
}
|
|
}
|
|
|
|
func TestSaobeiRefundTreatsPayingAsPending(t *testing.T) {
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error) {
|
|
return &saobei.RefundRsp{
|
|
RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: saobei.ResultCodePaying},
|
|
TerminalTrace: "LOCAL-REFUND-3",
|
|
OutRefundNo: "SAOBEI-REFUND-3",
|
|
RefundFee: "25",
|
|
}, nil
|
|
}})
|
|
|
|
result, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{
|
|
TradeNo: "LOCAL-3", RefundNo: "LOCAL-REFUND-3", Amount: 25, Currency: "CNY",
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Refund() error = %v", err)
|
|
}
|
|
if result.Status != "pending" || result.ProviderTradeNo != "SAOBEI-REFUND-3" {
|
|
t.Fatalf("Refund() result = %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestSaobeiRefundRejectsMismatchedAmount(t *testing.T) {
|
|
adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error) {
|
|
return &saobei.RefundRsp{
|
|
RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"},
|
|
TerminalTrace: "LOCAL-REFUND-1",
|
|
OutRefundNo: "SAOBEI-REFUND-1",
|
|
RefundFee: "51",
|
|
}, nil
|
|
}})
|
|
|
|
if _, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{
|
|
TradeNo: "LOCAL-1", RefundNo: "LOCAL-REFUND-1", Amount: 50, Currency: "CNY",
|
|
}, nil); err == nil {
|
|
t.Fatal("Refund() accepted a mismatched refund amount")
|
|
}
|
|
}
|