merge: 补测试批A(fake三态/MockChannel装配/devmock/currency_mismatch/限流回收)

This commit is contained in:
wangjia
2026-07-11 08:51:35 +08:00
5 changed files with 353 additions and 0 deletions
+60
View File
@@ -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)
}
}
+113
View File
@@ -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)
}
}
+48
View File
@@ -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())
}
}
}