Files
pay/internal/gateway/settle_test.go
T

199 lines
7.9 KiB
Go

package gateway_test
import (
"context"
"encoding/json"
"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"
)
func attemptRef(t *testing.T, orders interface {
ListAttemptsByStatus(model.AttemptStatus, int) ([]model.Attempt, error)
}) string {
t.Helper()
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
if len(atts) == 0 {
t.Fatalf("无 pending 尝试")
}
return atts[0].ProviderRef
}
func TestSettleHappyIdempotentAndWebhook(t *testing.T) {
g, _, spy, orders := newGateway(t)
ctx := context.Background()
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
ref := attemptRef(t, orders)
ev := &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}
got, err := g.Settle(ctx, ev)
if err != nil || got != gateway.SettleProcessed {
t.Fatalf("settle#1 = %v, %v", got, err)
}
// 订单已 paid
o, _ := orders.GetOrder(res.OrderNo)
if o.Status != model.OrderPaidV2 {
t.Fatalf("order 应 paid, got %v", o.Status)
}
// webhook 入队一次,payload 带 event_type
if len(spy.calls) != 1 || spy.calls[0]["event_type"] != "payment.succeeded" || spy.calls[0]["out_trade_no"] != res.OrderNo {
t.Fatalf("webhook calls = %+v", spy.calls)
}
// 幂等:再 settle → duplicate,不重复入队
got2, _ := g.Settle(ctx, ev)
if got2 != gateway.SettleDuplicate || len(spy.calls) != 1 {
t.Fatalf("settle#2 = %v, calls=%d", got2, len(spy.calls))
}
}
func TestSettleWebhookCarriesProductBizCode(t *testing.T) {
g, fp, spy, orders := newGateway(t)
ctx := context.Background()
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-9",
})
ref := attemptRef(t, orders)
fp.SetQueryResult(ref, provider.PaidEvent{
ProviderRef: ref, Status: provider.PaidSucceeded,
PaidAmountMinor: 29990000, PaidCurrency: "USDT",
})
if _, err := g.SyncPendingAttempts(ctx, 10); err != nil {
t.Fatalf("sync: %v", err)
}
if len(spy.calls) != 1 {
t.Fatalf("want 1 webhook, got %d", len(spy.calls))
}
if spy.calls[0]["product_biz_code"] != "pro_year" {
t.Fatalf("payload product_biz_code = %v, want pro_year", spy.calls[0]["product_biz_code"])
}
_ = res
}
func TestSettleGuards(t *testing.T) {
g, _, spy, orders := newGateway(t)
ctx := context.Background()
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
ref := attemptRef(t, orders)
// 未 succeeded → ignored
if got, _ := g.Settle(ctx, &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidPending}); got != gateway.SettleIgnored {
t.Fatalf("pending 应 ignored, got %v", got)
}
// 未知 ref → not_found
if got, _ := g.Settle(ctx, &provider.PaidEvent{ProviderRef: "GHOST", Status: provider.PaidSucceeded, PaidCurrency: "USDT", PaidAmountMinor: 1}); got != gateway.SettleNotFound {
t.Fatalf("未知 ref 应 not_found, got %v", got)
}
// 少付 → amount_mismatch
if got, err := g.Settle(ctx, &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidCurrency: "USDT", PaidAmountMinor: 1}); got != gateway.SettleAmountMismatch || err == nil {
t.Fatalf("少付应 amount_mismatch, got %v %v", got, err)
}
// 错币种 → amount_mismatch
if got, _ := g.Settle(ctx, &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidCurrency: "CNY", PaidAmountMinor: 29990000}); got != gateway.SettleAmountMismatch {
t.Fatalf("错币种应 amount_mismatch, got %v", got)
}
if len(spy.calls) != 0 {
t.Fatalf("守卫失败路径不应入队 webhook, got %d", len(spy.calls))
}
}
// 资金命脉不变量:outbox 入队失败 → 绝不翻转订单(否则"已付但永不通知")。
// 渠道拿不到 200 会重投,重投时入队+翻转都幂等,自然恢复。
func TestSettleEnqueueFailureKeepsOrderPending(t *testing.T) {
g, _, spy, orders := newGateway(t)
ctx := context.Background()
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
ref := attemptRef(t, orders)
ev := &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}
spy.failNext = true
if got, err := g.Settle(ctx, ev); got != gateway.SettleFailed || err == nil {
t.Fatalf("入队失败应 SettleFailed+err, got %v, %v", got, err)
}
o, _ := orders.GetOrder(res.OrderNo)
if o.Status != model.OrderPendingV2 {
t.Fatalf("入队失败后订单必须仍 pending, got %v", o.Status)
}
// 渠道重投 → 入队成功 → 翻转
if got, err := g.Settle(ctx, ev); err != nil || got != gateway.SettleProcessed {
t.Fatalf("重投应 processed, got %v, %v", got, err)
}
if len(spy.calls) != 1 {
t.Fatalf("恢复后应恰入队 1 次, got %d", len(spy.calls))
}
}
// 瞬时读库失败(非 store.ErrAttemptNotFound 哨兵)必须归 SettleFailed(可重试),
// 不能与"查无此单"的终态 SettleNotFound 混淆——否则调用方按结果值决定 ack,
// 会把已付订单永久丢弃。构造方式:先建好订单/尝试,再直接关掉底层连接,
// 让 AttemptByProviderRef 打到一个已关闭的 DB 上,产出非哨兵错误。
func TestSettleTransientReadErrorIsFailed(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()
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
ctx := context.Background()
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
ref := attemptRef(t, orders)
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db.DB(): %v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
ev := &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}
got, err := g.Settle(ctx, ev)
if got != gateway.SettleFailed || err == nil {
t.Fatalf("瞬时读库失败应 SettleFailed+err, got %v, %v", got, err)
}
}
func TestHandleCallbackAndSync(t *testing.T) {
g, fp, _, orders := newGateway(t)
ctx := context.Background()
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
ref := attemptRef(t, orders)
// 回调路径:fake.VerifyCallback 解析 JSON → Settle
body, _ := json.Marshal(map[string]any{"provider_ref": ref, "status": "succeeded", "amount_minor": 29990000, "currency": "USDT"})
got, err := g.HandleCallback(ctx, "fake", provider.CallbackInput{Raw: body})
if err != nil || got != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v", got, err)
}
// 查单兜底:另起一单,预置 query 命中 → SyncPendingAttempts 收敛
res2, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-2"})
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
ref2 := atts[0].ProviderRef
fp.SetQueryResult(ref2, provider.PaidEvent{ProviderRef: ref2, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"})
n, err := g.SyncPendingAttempts(ctx, 10)
if err != nil || n < 1 {
t.Fatalf("SyncPendingAttempts = %d, %v", n, err)
}
o2, _ := orders.GetOrder(res2.OrderNo)
if o2.Status != model.OrderPaidV2 {
t.Fatalf("查单兜底后 order 应 paid, got %v", o2.Status)
}
}