0ae7769abe
- 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,不是乘除法坑)。
152 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|