kra-new/internal/integration/payment/saobei_test.go

311 lines
12 KiB
Go

package payment
import (
"context"
bizpayment "kra/internal/biz/payment"
"testing"
"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(), &bizpayment.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(), &bizpayment.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(), &bizpayment.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(), &bizpayment.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(), &bizpayment.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(), &bizpayment.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(), &bizpayment.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(), &bizpayment.PaymentRefundRequest{
TradeNo: "LOCAL-1", RefundNo: "LOCAL-REFUND-1", Amount: 50, Currency: "CNY",
}, nil); err == nil {
t.Fatal("Refund() accepted a mismatched refund amount")
}
}