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) } }