merge: 补测试批A(fake三态/MockChannel装配/devmock/currency_mismatch/限流回收)
This commit is contained in:
@@ -221,3 +221,63 @@ func TestCreateOrderCurrencyFromChannelCapability(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
"github.com/wangjia/pay/internal/accounts"
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
"github.com/wangjia/pay/internal/handler"
|
||||
"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"
|
||||
"github.com/wangjia/pay/internal/webhook"
|
||||
)
|
||||
|
||||
// buildDevMockEngine 装配一个带 DevMockHandler 的最小 gin engine:与 main.go 的
|
||||
// MOCK_CHANNEL_ENABLED 挂载点(POST /api/v2/dev/orders/:order_no/mark-paid)同形,
|
||||
// 但走真实 webhook.Notifier + store.WebhookStore(而非 nopEnqueuer),这样才能断言
|
||||
// mark-paid 触发的 webhook 出站 outbox 真的入队了一行——handler 包既有的
|
||||
// buildEngine()/buildEngineWithStore() 都用 nopEnqueuer,查不到 outbox。
|
||||
func buildDevMockEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.OrderStore, *store.WebhookStore) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
chargebacks := store.NewChargebackStore(db)
|
||||
webhookStore := store.NewWebhookStore(db)
|
||||
|
||||
preg := provider.NewRegistry()
|
||||
preg.Register(fake.New())
|
||||
areg := accounts.New([]config.AccountConfig{
|
||||
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
|
||||
// bizConfig/orderPaid 只是 Notifier 投递(DeliverPending)才用得到;本测试只验证
|
||||
// Enqueue 落 outbox 一行,不跑投递循环,给最简单的桩即可。
|
||||
notifier := webhook.NewNotifier(webhookStore,
|
||||
func(string) (config.BizSystemConfig, bool) { return config.BizSystemConfig{}, true },
|
||||
func(string) (bool, error) { return true, nil },
|
||||
)
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, notifier, "global", subs, chargebacks)
|
||||
|
||||
devH := handler.NewDevMockHandler(db, g)
|
||||
r := gin.New()
|
||||
r.POST("/api/v2/dev/orders/:order_no/mark-paid", devH.MarkPaid)
|
||||
return r, g, orders, webhookStore
|
||||
}
|
||||
|
||||
// TestDevMockMarkPaid 覆盖 dev-only「模拟付款成功」端点的最小闭环:fake 渠道下单
|
||||
// (带 biz_system,否则 Settle 不会入队 webhook,见 gateway/settle.go
|
||||
// enqueuePaymentSucceeded)→ POST mark-paid → 200 + 订单翻 paid + webhook outbox
|
||||
// 有一行 payment.succeeded 待投递。
|
||||
func TestDevMockMarkPaid(t *testing.T) {
|
||||
r, g, orders, webhookStore := buildDevMockEngine(t)
|
||||
ctx := t.Context()
|
||||
|
||||
res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{
|
||||
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrder: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/dev/orders/"+res.OrderNo+"/mark-paid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("mark-paid code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
o, err := orders.GetOrder(res.OrderNo)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrder: %v", err)
|
||||
}
|
||||
if o.Status != model.OrderPaidV2 {
|
||||
t.Fatalf("订单状态 = %v, want paid", o.Status)
|
||||
}
|
||||
|
||||
rows, err := webhookStore.ListUndelivered(10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUndelivered: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, row := range rows {
|
||||
if row.OutTradeNo == res.OrderNo && row.EventType == "payment.succeeded" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("webhook outbox 应有一行 %s/payment.succeeded 待投递, rows=%+v", res.OrderNo, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevMockMarkPaidOrderNotFound 覆盖找不到订单(未走 fake 渠道下单/order_no 错)
|
||||
// 时的 404 分支,不误判成 500。
|
||||
func TestDevMockMarkPaidOrderNotFound(t *testing.T) {
|
||||
r, _, _, _ := buildDevMockEngine(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/dev/orders/GHOST-ORDER/mark-paid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("未知订单 mark-paid code=%d, want 404, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jiu 反馈波安全 review item 2:攻击者可用无限伪造 IP(哪怕已按 SetTrustedProxies(nil)
|
||||
@@ -38,3 +39,83 @@ func TestIPRateLimiterBucketMapBounded(t *testing.T) {
|
||||
t.Fatalf("超限请求后 buckets map 不应继续增长: size=%d max=%d", sizeAfterOverflow, maxBuckets)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPRateLimiterSweepLocked_EvictsStaleKeepsFresh 白盒直测 sweepLocked 本身的
|
||||
// 回收语义(补 TestIPRateLimiterBucketMapBounded 没覆盖到的部分——那条只验证
|
||||
// map 有硬上限,没验证"过期 bucket 真的会被回收"这件事):>10min 无请求的桶应被
|
||||
// 回收,近期活跃的桶应保留。updated 字段用白盒测试直接拨表,不必真等 10 分钟。
|
||||
func TestIPRateLimiterSweepLocked_EvictsStaleKeepsFresh(t *testing.T) {
|
||||
rl := NewIPRateLimiter(1)
|
||||
now := time.Now()
|
||||
|
||||
rl.allow("stale-ip")
|
||||
rl.allow("fresh-ip")
|
||||
|
||||
rl.mu.Lock()
|
||||
stale := rl.buckets["stale-ip"]
|
||||
fresh := rl.buckets["fresh-ip"]
|
||||
rl.mu.Unlock()
|
||||
|
||||
stale.mu.Lock()
|
||||
stale.updated = now.Add(-11 * time.Minute) // 超过 10min 阈值
|
||||
stale.mu.Unlock()
|
||||
|
||||
fresh.mu.Lock()
|
||||
fresh.updated = now.Add(-1 * time.Minute) // 未超阈值
|
||||
fresh.mu.Unlock()
|
||||
|
||||
rl.mu.Lock()
|
||||
rl.sweepLocked(now)
|
||||
_, staleStillThere := rl.buckets["stale-ip"]
|
||||
_, freshStillThere := rl.buckets["fresh-ip"]
|
||||
rl.mu.Unlock()
|
||||
|
||||
if staleStillThere {
|
||||
t.Fatalf("超过 10min 不活跃的桶应被 sweepLocked 回收,仍在 map 中")
|
||||
}
|
||||
if !freshStillThere {
|
||||
t.Fatalf("近期活跃(<10min)的桶不应被误回收")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPRateLimiterOverflowRecoversViaStaleSweep 端到端验证:硬上限已满时,一个
|
||||
// 早已沉寂(>10min 不活跃)的桶会在下一次 allow() 触发的 sweep 里被回收腾出位置,
|
||||
// 新 IP 能正常建桶并放行——而不是被永久拒绝。这是 maxBuckets backstop 之外真实
|
||||
// 存在的自愈路径(allow() 里 len(buckets)>=maxBuckets 分支先 sweepLocked 再判定,
|
||||
// 见 ratelimit.go)。
|
||||
func TestIPRateLimiterOverflowRecoversViaStaleSweep(t *testing.T) {
|
||||
rl := NewIPRateLimiter(1)
|
||||
|
||||
for i := 0; i < maxBuckets; i++ {
|
||||
rl.allow(fmt.Sprintf("filler-ip-%d", i))
|
||||
}
|
||||
|
||||
// 把其中一个桶拨成 11 分钟前不活跃,模拟"曾经的来源已沉寂"。
|
||||
rl.mu.Lock()
|
||||
b := rl.buckets["filler-ip-0"]
|
||||
rl.mu.Unlock()
|
||||
b.mu.Lock()
|
||||
b.updated = time.Now().Add(-11 * time.Minute)
|
||||
b.mu.Unlock()
|
||||
|
||||
// map 已满,新 IP 进来触发 sweep:应回收 filler-ip-0 的位置,新 IP 首次请求
|
||||
// (满 burst)应放行,而不是被当成"硬上限已满"直接拒绝。
|
||||
if !rl.allow("new-ip-after-sweep") {
|
||||
t.Fatalf("stale 桶应被 sweep 回收腾出位置,新 IP 首次请求应放行,却被拒绝")
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
_, staleStillThere := rl.buckets["filler-ip-0"]
|
||||
_, newThere := rl.buckets["new-ip-after-sweep"]
|
||||
size := len(rl.buckets)
|
||||
rl.mu.Unlock()
|
||||
if staleStillThere {
|
||||
t.Fatalf("stale 桶 filler-ip-0 应已被回收")
|
||||
}
|
||||
if !newThere {
|
||||
t.Fatalf("新 IP 应已建桶")
|
||||
}
|
||||
if size > maxBuckets {
|
||||
t.Fatalf("回收后总数不应超过硬上限: size=%d max=%d", size, maxBuckets)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,54 @@ func TestFakeCreateAndVerify(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeCreateRedirect 覆盖 Create 按 metadata["render"]="redirect" 出的 redirect
|
||||
// 三态分支(pangolin /buy 联调门控,见 fake.go Create 注释)。
|
||||
func TestFakeCreateRedirect(t *testing.T) {
|
||||
p := fake.New()
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := p.Create(ctx, provider.CreateRequest{
|
||||
OutTradeNo: "PAY-REDIRECT-1", AmountMinor: 100, Currency: "USDT",
|
||||
Metadata: map[string]string{"render": "redirect"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if sess.RenderType != provider.RenderRedirect {
|
||||
t.Fatalf("render_type = %v, want redirect", sess.RenderType)
|
||||
}
|
||||
url, _ := sess.Payload["url"].(string)
|
||||
if !strings.Contains(url, "mock-cashier") || !strings.Contains(url, "PAY-REDIRECT-1") {
|
||||
t.Fatalf("payload.url = %q, want 含 mock-cashier 与 out_trade_no", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeCreateQR 覆盖 metadata["render"]="qr" 分支。
|
||||
func TestFakeCreateQR(t *testing.T) {
|
||||
p := fake.New()
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := p.Create(ctx, provider.CreateRequest{
|
||||
OutTradeNo: "PAY-QR-1", AmountMinor: 12345, Currency: "CNY",
|
||||
Metadata: map[string]string{"render": "qr"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if sess.RenderType != provider.RenderQR {
|
||||
t.Fatalf("render_type = %v, want qr", sess.RenderType)
|
||||
}
|
||||
if _, ok := sess.Payload["qr_content"]; !ok {
|
||||
t.Fatalf("payload 缺 qr_content: %+v", sess.Payload)
|
||||
}
|
||||
if got := sess.Payload["display_amount"]; got != "12345" {
|
||||
t.Fatalf("payload.display_amount = %v, want 12345", got)
|
||||
}
|
||||
if got := sess.Payload["currency"]; got != "CNY" {
|
||||
t.Fatalf("payload.currency = %v, want CNY", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeQueryPreset(t *testing.T) {
|
||||
p := fake.New()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -96,3 +96,54 @@ func TestBuildRegistry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRegistryMockChannelEnabled 覆盖 config.C.MockChannelEnabled=true 时的
|
||||
// fake 装配分支:注册表含 "fake",且其结算币种被设成 ["CNY","USDT"](CNY 排首位,
|
||||
// 对齐 pangolin 下单实际结算币种,见 registry.go 顶部注释)。config.C 是包级全局,
|
||||
// 测试内直接改字段 + defer 复原,避免污染同包/其它包测试的默认值(与
|
||||
// handler 包 ratelimit_test.go / refund_test.go 的 config.C 约定一致)。
|
||||
func TestBuildRegistryMockChannelEnabled(t *testing.T) {
|
||||
prev := config.C.MockChannelEnabled
|
||||
config.C.MockChannelEnabled = true
|
||||
defer func() { config.C.MockChannelEnabled = prev }()
|
||||
|
||||
acctReg := accounts.New(nil)
|
||||
reg := providerbuild.BuildRegistry(acctReg)
|
||||
|
||||
methods := reg.Methods()
|
||||
found := false
|
||||
for _, m := range methods {
|
||||
if m == "fake" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("MockChannelEnabled=true 时 Methods() 应含 fake, got %v", methods)
|
||||
}
|
||||
|
||||
fp, err := reg.Get("fake")
|
||||
if err != nil {
|
||||
t.Fatalf("Get(fake): %v", err)
|
||||
}
|
||||
caps := fp.Capabilities()
|
||||
if len(caps.SettleCurrencies) == 0 || caps.SettleCurrencies[0] != "CNY" {
|
||||
t.Fatalf("mock 装配应把 fake 结算币种设成 CNY 优先, got %v", caps.SettleCurrencies)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRegistryMockChannelDisabledByDefault 反向覆盖:MockChannelEnabled=false
|
||||
// (生产默认值)时注册表不含 "fake",证明 mock 渠道对生产零影响。
|
||||
func TestBuildRegistryMockChannelDisabledByDefault(t *testing.T) {
|
||||
prev := config.C.MockChannelEnabled
|
||||
config.C.MockChannelEnabled = false
|
||||
defer func() { config.C.MockChannelEnabled = prev }()
|
||||
|
||||
acctReg := accounts.New(nil)
|
||||
reg := providerbuild.BuildRegistry(acctReg)
|
||||
|
||||
for _, m := range reg.Methods() {
|
||||
if m == "fake" {
|
||||
t.Fatalf("MockChannelEnabled=false(默认)时 Methods() 不应含 fake, got %v", reg.Methods())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user