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