Files
pay/internal/gateway/e2e_fullchain_test.go
T
wangjia 0ae7769abe test(pay): 补 settle 并发/跨包全链 e2e/reconcile 装配/alipay 验签健壮性/money 零小数位
- gateway: TestSettleConcurrentSameOrder 真并发钉住 MarkAttemptPaid 行锁不变量
  (文件型 sqlite,规避 in-memory cache=shared 的 SQLITE_LOCKED_SHAREDCACHE)。
- gateway: e2e_fullchain_test.go 补下单→回调→settle→webhook 实际 HTTP 投递→
  delivered 整链(httptest server + 真实 store.WebhookStore/webhook.Notifier)。
- reconcile: main.go 装配抽到 reconcile.Assemble(+Runner.TaskNames 访问器),
  补 assembly_test.go 钉住 7 个后台任务全部注册 + crypto 缺渠道/interval=0 分支。
- alipay: 补验签健壮性(未知多余字段/字段乱序/中文unicode/空值字段),意外定位
  vendor Encoder 对空值字段的真实语义与官方文档描述不同,已记录在测试注释。
- money: 补 TestZeroDecimalCurrencyJPYNotSupported,记录 JPY/KRW 当前未注册进
  exponents(接入会先踩 ErrUnknownCurrency,不是乘除法坑)。
2026-07-11 08:50:45 +08:00

152 lines
5.8 KiB
Go

package gateway_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"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"
"github.com/wangjia/pay/internal/util"
"github.com/wangjia/pay/internal/webhook"
)
// TestE2EFullChainCallbackToDeliveredWebhook 补齐「下单 → 回调 → settle → webhook
// 实际 HTTP 投递 → 标 delivered」整链(各层此前只独立测过):
// - 起一个 httptest.Server 当业务方回调接收器,记录收到的请求,验证 X-Pay-* 头齐全 +
// 能用同一 secret 验签通过(出站签名互操作,与 internal/webhook/notifier_test.go 同法)。
// - fake 渠道下单 → 模拟渠道成功回调(HandleCallback,同 settle_test.go
// TestHandleCallbackAndSync 的回调体) → Settle 翻转 paid → outbox 入队。
// - 用真实 store.WebhookStore + webhook.Notifier(非 spyEnqueuer)驱动一次
// DeliverPending(即 notifier 的投递 RunOnce)→ 断言 webhook 真被投到 test server、
// server 收到的 payload 签名正确、outbox 行标记 delivered。
func TestE2EFullChainCallbackToDeliveredWebhook(t *testing.T) {
const secret = "e2e-fullchain-secret"
const bizSystem = "pangolin"
var gotBody []byte
var gotHeaders http.Header
var hits int
biz := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
gotBody, _ = io.ReadAll(r.Body)
gotHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("SUCCESS"))
}))
defer biz.Close()
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)
ws := store.NewWebhookStore(db)
// bizConfig:biz_system "pangolin" 的回调地址指向这台 test server(生产由
// config.C.BizByName 按业务方名下发,这里直接指定同等语义)。
bizCfg := func(system string) (config.BizSystemConfig, bool) {
if system == bizSystem {
return config.BizSystemConfig{CallbackURL: biz.URL, Secret: secret}, true
}
return config.BizSystemConfig{}, false
}
// orderPaid 门禁走真实订单查询(不像多数单测那样恒真),验证投递门禁与真实订单状态联动。
orderPaid := func(outTradeNo string) (bool, error) {
o, err := orders.GetOrder(outTradeNo)
if err != nil {
return false, err
}
return o.Status == model.OrderPaidV2, nil
}
notifier := webhook.NewNotifier(ws, bizCfg, orderPaid)
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, notifier, "global", subs, chargebacks)
ctx := context.Background()
res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: bizSystem, BizRef: "u-e2e-full",
})
if err != nil {
t.Fatalf("create order: %v", err)
}
ref := attemptRef(t, orders)
// 渠道方成功回调(同 TestHandleCallbackAndSync 的回调体)→ VerifyCallback → 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, want SettleProcessed", got, err)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil || o.Status != model.OrderPaidV2 {
t.Fatalf("order = %+v, %v, want paid", o, err)
}
// 入队后未投递:确认 outbox 里躺着一条待发行。
pend, err := ws.ListUndelivered(10)
if err != nil || len(pend) != 1 || pend[0].OutTradeNo != res.OrderNo {
t.Fatalf("undelivered = %+v, %v, want 1 row for %s", pend, err, res.OrderNo)
}
// notifier 投递一轮(RunOnce 语义)→ 真实 HTTP POST 到业务方接收器。
sent, err := notifier.DeliverPending(10)
if err != nil || sent != 1 {
t.Fatalf("DeliverPending = %d, %v, want 1", sent, err)
}
if hits != 1 {
t.Fatalf("业务方应恰收到 1 次 POST, got %d", hits)
}
// 出站签名头齐全 + 能用同一 secret 验签通过(互操作校验,业务方即用同法验签)。
sys := gotHeaders.Get("X-Pay-System")
ev := gotHeaders.Get("X-Pay-Event")
ts := gotHeaders.Get("X-Pay-Timestamp")
nonce := gotHeaders.Get("X-Pay-Nonce")
sign := gotHeaders.Get("X-Pay-Sign")
if sys != bizSystem || ev != "payment.succeeded" || ts == "" || nonce == "" || sign == "" {
t.Fatalf("X-Pay-* 头不全: system=%q event=%q ts=%q nonce=%q sign=%q", sys, ev, ts, nonce, sign)
}
if !util.HMACVerify(secret, sign, sys, ts, nonce, string(gotBody)) {
t.Fatalf("业务方侧验签失败(出站签名与 HMACVerify 不互操作)")
}
var payload map[string]any
if err := json.Unmarshal(gotBody, &payload); err != nil {
t.Fatalf("payload 非法 JSON: %v, body=%s", err, gotBody)
}
if payload["event_type"] != "payment.succeeded" || payload["out_trade_no"] != res.OrderNo ||
payload["product_biz_code"] != "pro_year" {
t.Fatalf("payload = %+v", payload)
}
// outbox 行已标 delivered:再投一次不应重发。
pendAfter, err := ws.ListUndelivered(10)
if err != nil || len(pendAfter) != 0 {
t.Fatalf("投递后 undelivered = %+v, %v, want 0(已标 delivered)", pendAfter, err)
}
if sent2, _ := notifier.DeliverPending(10); sent2 != 0 {
t.Fatalf("已投递不应重发, got %d", sent2)
}
if hits != 1 {
t.Fatalf("重投检查不应产生新 POST, hits=%d", hits)
}
}