Files
pay/internal/handler/gateway_test.go
T

165 lines
5.7 KiB
Go

package handler_test
import (
"bytes"
"encoding/json"
"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/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/provider/fake"
"github.com/wangjia/pay/internal/router"
"github.com/wangjia/pay/internal/store"
)
type nopEnqueuer struct{}
func (nopEnqueuer) Enqueue(string, string, string, map[string]any) error { return nil }
type oneResolver struct{}
func (oneResolver) Resolve(sku string) (int64, string, string, string, error) {
return 29990000, "USDT", "Pro 年付", "pro_year", nil
}
func buildEngine(t *testing.T) *gin.Engine {
t.Helper()
r, _ := buildEngineWithStore(t)
return r
}
func buildEngineWithStore(t *testing.T) (*gin.Engine, *store.OrderStore) {
t.Helper()
gin.SetMode(gin.TestMode)
orders := store.NewOrderStore(model.OpenTestDB(t))
preg := provider.NewRegistry()
preg.Register(fake.New())
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
g := gateway.New(orders, preg, areg, oneResolver{}, nopEnqueuer{}, "global")
r := gin.New()
router.SetupV2(r, g)
return r, orders
}
func do(t *testing.T, r *gin.Engine, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w, out
}
func TestV2OrderLifecycle(t *testing.T) {
r := buildEngine(t)
// 下单(独立收款,无 biz_system → 无需签名)
w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{"sku": "pro_year", "method": "fake"})
if w.Code != http.StatusOK {
t.Fatalf("create code=%d body=%v", w.Code, out)
}
data := out["data"].(map[string]any)
orderNo := data["order_no"].(string)
sess := data["session"].(map[string]any)
if sess["render_type"] != "crypto_address" {
t.Fatalf("session = %v", sess)
}
// 查单:pending
_, out2 := do(t, r, http.MethodGet, "/api/v2/orders/"+orderNo, nil)
if out2["data"].(map[string]any)["status"] != "pending" {
t.Fatalf("status = %v", out2["data"])
}
// 取 provider_ref:直接构造 fake 回调体(provider_ref 由 payload 无法拿,需查尝试)
// 这里用 callback 端点 + fake JSON:先取尝试 ref。测试通过再次下单太绕,
// 改为:回调体里 provider_ref 用 order_no 反查——fake ref 前缀 FAKE-<orderNo>。
// 直接命中:构造 verify 输入需真实 ref,故经 /api/v2/callback 前先查库拿 ref。
// 简化:暴露一个内部查询——本测试用 status 已足够验证下单/查单闭环;
// 回调闭环在 gateway settle_test 已覆盖。此处验证 callback 路由存在且 404 语义:
wc, _ := do(t, r, http.MethodPost, "/api/v2/callback/fake", map[string]any{
"provider_ref": "GHOST", "status": "succeeded", "amount_minor": 1, "currency": "USDT",
})
if wc.Code != http.StatusOK { // not_found 也回 200(渠道无需重投未知单)
t.Fatalf("callback code=%d", wc.Code)
}
// 取消
wCancel, outCancel := do(t, r, http.MethodPost, "/api/v2/orders/"+orderNo+"/cancel", nil)
if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true {
t.Fatalf("cancel = %d %v", wCancel.Code, outCancel)
}
}
// TestV2CallbackAmountMismatchIs200Terminal 覆盖 P2-7 审查发现:金额/币种不符是终态,
// 渠道无需(也不该)重投,回调必须回 200 停投,而不是 400 触发无限重投。
func TestV2CallbackAmountMismatchIs200Terminal(t *testing.T) {
r, orders := buildEngineWithStore(t)
w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{"sku": "pro_year", "method": "fake"})
if w.Code != http.StatusOK {
t.Fatalf("create code=%d body=%v", w.Code, out)
}
orderNo := out["data"].(map[string]any)["order_no"].(string)
atts, err := orders.ListAttemptsByStatus(model.AttemptPending, 10)
if err != nil || len(atts) == 0 {
t.Fatalf("无 pending 尝试: %v", err)
}
var ref string
for _, a := range atts {
if a.OutTradeNo == orderNo {
ref = a.ProviderRef
}
}
if ref == "" {
t.Fatalf("未找到订单 %s 的尝试", orderNo)
}
// 币种正确但少付(amount_minor=1 << 应收 29990000)→ amount_mismatch,终态,必须 200。
wc, outc := do(t, r, http.MethodPost, "/api/v2/callback/fake", map[string]any{
"provider_ref": ref, "status": "succeeded", "amount_minor": 1, "currency": "USDT",
})
if wc.Code != http.StatusOK {
t.Fatalf("amount_mismatch callback code=%d body=%v (终态应 200 停投,不应 400 触发无限重投)", wc.Code, outc)
}
if outc["result"] != string(gateway.SettleAmountMismatch) {
t.Fatalf("result = %v, want amount_mismatch", outc["result"])
}
// 订单应仍是 pending(未被误标为已支付)。
_, outStatus := do(t, r, http.MethodGet, "/api/v2/orders/"+orderNo, nil)
if outStatus["data"].(map[string]any)["status"] != "pending" {
t.Fatalf("order status = %v, want pending", outStatus["data"])
}
}
// TestV2CallbackMalformedBodyIs400Transient 验签/解析失败是瞬时态,渠道应重投,故回 400。
func TestV2CallbackMalformedBodyIs400Transient(t *testing.T) {
r := buildEngine(t)
req := httptest.NewRequest(http.MethodPost, "/api/v2/callback/fake", bytes.NewBufferString("not-json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("malformed body callback code=%d, want 400", w.Code)
}
}