Files
pay/internal/gateway/gateway_test.go
T

284 lines
11 KiB
Go

package gateway_test
import (
"context"
"errors"
"testing"
"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/store"
)
// --- 测试替身 ---
type stubResolver struct{}
func (stubResolver) Resolve(sku, currency string) (int64, string, string, error) {
if sku != "pro_year" {
return 0, "", "", gateway.ErrProductNotFound
}
// 结算币种驱动金额:USDT 6 位, 其余按分。测试只用 fake(USDT)。
switch currency {
case "USDT":
return 29990000, "Pro 年付", "pro_year", nil
case "CNY":
return 19900, "Pro 年付", "pro_year", nil
default:
return 0, "", "", gateway.ErrProductNotFound
}
}
type spyEnqueuer struct {
calls []map[string]any
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用),命中后消费一次即清零。
// failOnEventType 非空时,下一次遇到该 event_type 的入队调用失败一次(消费后清空)——用于
// 测试"同一 HandleCallback 内先后两次 Enqueue,只让第二次(如 subscription.created)失败,
// 第一次(如 payment.succeeded)先成功"这类场景,failNext 做不到按 event_type 区分。
failOnEventType string
seen map[string]bool // 镜像真实 WebhookStore.EnqueueDelivery 的 (out_trade_no,event_type,
// refund_id) 唯一键 ON CONFLICT DO NOTHING:重复 key 静默 no-op(不追加 calls,也不算失败)。
// 修复"重投补入队自愈"后,业务代码会在 duplicate/重投分支也调用 Enqueue,若 spy 仍是无脑
// 计数器就会把 outbox 天然幂等的重复行误判成"多发了一次 webhook",这里镜像真实幂等语义。
}
func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error {
if s.failNext {
s.failNext = false
return errors.New("outbox down")
}
if s.failOnEventType != "" && eventType == s.failOnEventType {
s.failOnEventType = ""
return errors.New("outbox down")
}
key := outTradeNo + "|" + eventType + "|" + refundID
if s.seen == nil {
s.seen = make(map[string]bool)
}
if s.seen[key] {
return nil // 幂等 no-op,行已存在
}
s.seen[key] = true
s.calls = append(s.calls, data)
return nil
}
func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *store.OrderStore) {
t.Helper()
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)
// fake 注册在 method "fake";账户按 channel="fake" region="global" 配。
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
{AccountID: "fake-a2", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil) // 默认 round_robin
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
return g, fp, spy, orders
}
func TestCreateOrderPipeline(t *testing.T) {
g, _, _, orders := newGateway(t)
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("CreateOrder: %v", err)
}
if res.OrderNo == "" || res.Session.RenderType != string(provider.RenderCryptoAddress) {
t.Fatalf("result = %+v", res)
}
if res.Session.Payload["currency"] != "USDT" {
t.Fatalf("payload = %+v", res.Session.Payload)
}
// 落库:order pending + attempt pending 带 provider_ref。
o, err := orders.GetOrder(res.OrderNo)
if err != nil || o.Status != model.OrderPendingV2 || o.AmountMinor != 29990000 {
t.Fatalf("order = %+v, %v", o, err)
}
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
if len(atts) != 1 || atts[0].Channel != "fake" || atts[0].AccountID != "fake-a1" {
t.Fatalf("attempt = %+v", atts)
}
}
func TestCreateOrderPersistsBizCode(t *testing.T) {
g, _, _, orders := newGateway(t)
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("create: %v", err)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil {
t.Fatalf("get order: %v", err)
}
if o.BizCode != "pro_year" { // stubResolver 的 bizCode
t.Fatalf("order.BizCode = %q, want pro_year", o.BizCode)
}
}
func TestCreateOrderErrors(t *testing.T) {
g, _, _, _ := newGateway(t)
ctx := context.Background()
if _, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "nope", Method: "fake"}); err != gateway.ErrProductNotFound {
t.Fatalf("未知 sku 应 ErrProductNotFound, got %v", err)
}
if _, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "ghost"}); err == nil {
t.Fatalf("未知 method 应报错")
}
}
func TestRetryAndCancel(t *testing.T) {
g, _, _, orders := newGateway(t)
ctx := context.Background()
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
// retry:弃旧尝试 + 建新尝试(order 仍 pending)。
r2, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil)
if err != nil || r2.OrderNo != res.OrderNo {
t.Fatalf("retry = %+v, %v", r2, err)
}
pend, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
if len(pend) != 1 {
t.Fatalf("retry 后应恰 1 个 pending 尝试, got %d", len(pend))
}
// cancel pending → true;再 cancel → false。
ok, err := g.CancelOrder(res.OrderNo)
if err != nil || !ok {
t.Fatalf("cancel = %v, %v", ok, err)
}
if ok2, _ := g.CancelOrder(res.OrderNo); ok2 {
t.Fatalf("已取消单再取消应 false")
}
// canceled 单不可 retry。
if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil); err != gateway.ErrOrderNotPending {
t.Fatalf("canceled 单 retry 应 ErrOrderNotPending, got %v", err)
}
}
func TestRetrySwitchesAccount(t *testing.T) {
g, _, _, orders := newGateway(t)
ctx := context.Background()
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
// 首单落 fake-a1(round_robin 计数 0);retry 排除 a1 → 必落 fake-a2。
if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil); err != nil {
t.Fatalf("retry: %v", err)
}
pend, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
if len(pend) != 1 || pend[0].AccountID != "fake-a2" {
t.Fatalf("retry 应换到 fake-a2, got %+v", pend)
}
}
// jiu 反馈波 item 1:CreateOrderInput.Metadata / RetryOrder 的 metadata 参数须原样
// 透传到 provider.CreateRequest.Metadata,不能在 gateway 管线里被丢弃或改写。
func TestMetadataPassthroughToProvider(t *testing.T) {
g, fp, _, _ := newGateway(t)
ctx := context.Background()
res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
Metadata: map[string]string{"is_mobile": "1"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if got := fp.LastMetadata(); got["is_mobile"] != "1" {
t.Fatalf("CreateOrder 后 provider 收到的 metadata = %+v, want is_mobile=1", got)
}
if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", map[string]string{"render": "qr"}); err != nil {
t.Fatalf("retry: %v", err)
}
if got := fp.LastMetadata(); got["render"] != "qr" {
t.Fatalf("RetryOrder 后 provider 收到的 metadata = %+v, want render=qr", got)
}
}
func TestCreateOrderCurrencyFromChannelCapability(t *testing.T) {
g, _, _, orders := newGateway(t) // fake provider, SettleCurrencies=["USDT"]
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake"})
if err != nil {
t.Fatalf("create: %v", err)
}
o, _ := orders.GetOrder(res.OrderNo)
if o.Currency != "USDT" || o.AmountMinor != 29990000 {
t.Fatalf("order = %s/%d want USDT/29990000", o.Currency, o.AmountMinor)
}
}
// cnyOnlyProvider 是本测试文件专用的最小 stub provider(结算币种固定 CNY),
// 陪 fake(结算 USDT)凑出"两个结算币种不同的方法"场景,用来直测 RetryOrder 换渠道
// 触发 gateway.ErrCurrencyMismatch 的分支(gateway.go:177 附近)。不复用
// internal/provider/provider_test.go 的 stubProvider——那个类型未导出且在
// package provider_test 里,gateway_test 包拿不到。
type cnyOnlyProvider struct{}
func (cnyOnlyProvider) Method() string { return "cny-only" }
func (cnyOnlyProvider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderQR},
SettleCurrencies: []string{"CNY"},
}
}
func (cnyOnlyProvider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
return &provider.Session{ProviderRef: "CNYONLY-" + req.OutTradeNo, RenderType: provider.RenderQR, Payload: map[string]any{}}, nil
}
func (cnyOnlyProvider) VerifyCallback(context.Context, provider.CallbackInput) (*provider.PaidEvent, error) {
return nil, errors.New("cnyOnlyProvider: not implemented")
}
func (cnyOnlyProvider) Query(context.Context, provider.QueryRequest) (*provider.PaidEvent, error) {
return &provider.PaidEvent{Status: provider.PaidPending}, nil
}
// TestRetryOrderCurrencyMismatch 直测 gateway.go:177 附近的触发条件:换到一个结算
// 币种与订单不同的渠道 retry,必须报 ErrCurrencyMismatch(而不是静默换币重定价——
// P3 范围之外,见 RetryOrder 注释)。此前只在 handler 层经 HTTP 间接测到 409。
func TestRetryOrderCurrencyMismatch(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
preg.Register(fake.New()) // "fake" 结算 USDT
preg.Register(cnyOnlyProvider{}) // "cny-only" 结算 CNY
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
{AccountID: "cny-a1", Channel: "cny-only", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, &spyEnqueuer{}, "global", subs, chargebacks)
ctx := context.Background()
// 用 cny-only 下单(结算 CNY),再 retry 到 fake(结算 USDT)→ 币种不符。
res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "cny-only"})
if err != nil {
t.Fatalf("create: %v", err)
}
_, err = g.RetryOrder(ctx, res.OrderNo, "fake", nil)
if !errors.Is(err, gateway.ErrCurrencyMismatch) {
t.Fatalf("RetryOrder 换到结算币种不同的渠道应报 ErrCurrencyMismatch, got %v", err)
}
}