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 崩溃窗口测试用) } 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") } 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) 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) 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") 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"); 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"); 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) } } 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) } }