feat(v2): webhook v2 outbox + Notifier(event_type + HMAC 双向 + 重试兜底)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package webhook_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
"github.com/wangjia/pay/internal/util"
|
||||
"github.com/wangjia/pay/internal/webhook"
|
||||
)
|
||||
|
||||
func TestNotifierDeliversSignedEvent(t *testing.T) {
|
||||
const secret = "shh-secret"
|
||||
var gotBody []byte
|
||||
var gotHeaders http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
gotHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("SUCCESS"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ws := store.NewWebhookStore(model.OpenTestDB(t))
|
||||
bizCfg := func(system string) (config.BizSystemConfig, bool) {
|
||||
if system == "pangolin" {
|
||||
return config.BizSystemConfig{CallbackURL: srv.URL, Secret: secret}, true
|
||||
}
|
||||
return config.BizSystemConfig{}, false
|
||||
}
|
||||
alwaysPaid := func(string) (bool, error) { return true, nil }
|
||||
n := webhook.NewNotifier(ws, bizCfg, alwaysPaid)
|
||||
|
||||
// 经 Enqueuer 接口入队(gateway 就是这么调的)。
|
||||
err := n.Enqueue("PAY-1", "pangolin", "payment.succeeded", map[string]any{
|
||||
"event_type": "payment.succeeded", "out_trade_no": "PAY-1", "amount_minor": 29990000, "currency": "USDT",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
|
||||
sent, err := n.DeliverPending(10)
|
||||
if err != nil || sent != 1 {
|
||||
t.Fatalf("DeliverPending = %d, %v", sent, err)
|
||||
}
|
||||
|
||||
// 校验签名头(pay→业务方,双向 HMAC,业务方可同法验签)。
|
||||
sys := gotHeaders.Get("X-Pay-System")
|
||||
ts := gotHeaders.Get("X-Pay-Timestamp")
|
||||
nonce := gotHeaders.Get("X-Pay-Nonce")
|
||||
sign := gotHeaders.Get("X-Pay-Sign")
|
||||
if gotHeaders.Get("X-Pay-Event") != "payment.succeeded" {
|
||||
t.Fatalf("缺 X-Pay-Event 头")
|
||||
}
|
||||
if !util.HMACVerify(secret, sign, sys, ts, nonce, string(gotBody)) {
|
||||
t.Fatalf("签名校验失败")
|
||||
}
|
||||
// body 带 event_type
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(gotBody, &m)
|
||||
if m["event_type"] != "payment.succeeded" || m["out_trade_no"] != "PAY-1" {
|
||||
t.Fatalf("body = %s", gotBody)
|
||||
}
|
||||
// 已标投递:再投不重发
|
||||
if again, _ := n.DeliverPending(10); again != 0 {
|
||||
t.Fatalf("已投递不应重发, got %d", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierRetriesOnFailure(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusInternalServerError) // 业务方暂时挂
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ws := store.NewWebhookStore(model.OpenTestDB(t))
|
||||
n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
|
||||
return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x"}, true
|
||||
}, func(string) (bool, error) { return true, nil })
|
||||
_ = n.Enqueue("PAY-3", "pangolin", "payment.succeeded", map[string]any{"event_type": "payment.succeeded"})
|
||||
|
||||
if sent, _ := n.DeliverPending(10); sent != 0 {
|
||||
t.Fatalf("失败不应算投递成功, got %d", sent)
|
||||
}
|
||||
// 仍待投递,可被下一轮重试兜底
|
||||
pend, _ := ws.ListUndelivered(10)
|
||||
if len(pend) != 1 || pend[0].Attempts != 1 {
|
||||
t.Fatalf("失败后应留队重试, got %+v", pend)
|
||||
}
|
||||
if _, _ = n.DeliverPending(10); hits < 2 {
|
||||
t.Fatalf("应重试第二次, hits=%d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// 投递门禁:订单未付(settle 崩在"入队后、翻转前"的窗口)绝不把 payment.succeeded
|
||||
// 发给业务方;也不计失败次数,等订单翻转后自然放行。
|
||||
func TestNotifierGateSkipsUnpaidOrder(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("SUCCESS"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ws := store.NewWebhookStore(model.OpenTestDB(t))
|
||||
paid := false
|
||||
n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
|
||||
return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x"}, true
|
||||
}, func(string) (bool, error) { return paid, nil })
|
||||
_ = n.Enqueue("PAY-4", "pangolin", "payment.succeeded", map[string]any{"event_type": "payment.succeeded"})
|
||||
|
||||
if sent, _ := n.DeliverPending(10); sent != 0 || hits != 0 {
|
||||
t.Fatalf("未付单不应投递, sent=%d hits=%d", sent, hits)
|
||||
}
|
||||
pend, _ := ws.ListUndelivered(10)
|
||||
if len(pend) != 1 || pend[0].Attempts != 0 {
|
||||
t.Fatalf("门禁跳过不应计失败, got %+v", pend)
|
||||
}
|
||||
|
||||
paid = true // 订单翻转后放行
|
||||
if sent, _ := n.DeliverPending(10); sent != 1 || hits != 1 {
|
||||
t.Fatalf("翻转后应投递, sent=%d hits=%d", sent, hits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user