feat(server): /v1/pay 下单代理端点 + 购买台账(JWT 鉴权,user→biz_ref 映射)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// newHandlerRig: 假 pay + sqlite 台账 + chi 路由(URL 参数解析需要真实路由)。
|
||||
func newHandlerRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store) {
|
||||
t.Helper()
|
||||
db := openMigratedSQLite(t)
|
||||
seedUser(t, db, 1, "uuid-1")
|
||||
seedUser(t, db, 2, "uuid-2")
|
||||
if payFn == nil {
|
||||
payFn = func(http.ResponseWriter, *http.Request) {}
|
||||
}
|
||||
srv := fakePay(t, payFn)
|
||||
st := NewStore(db)
|
||||
h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/pay/catalog", h.Catalog)
|
||||
r.Post("/v1/pay/orders", h.CreateOrder)
|
||||
r.Get("/v1/pay/orders/{orderNo}", h.GetOrder)
|
||||
r.Post("/v1/pay/orders/{orderNo}/retry", h.Retry)
|
||||
r.Post("/v1/pay/orders/{orderNo}/cancel", h.Cancel)
|
||||
return r, st
|
||||
}
|
||||
|
||||
// authed 注入 uid(auth.RequireAuth 注入的就是 codes.CtxKeyUserID)。
|
||||
func authed(r *http.Request, uid int64) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, uid))
|
||||
}
|
||||
|
||||
func TestCreateOrder_ProxiesAndRecords(t *testing.T) {
|
||||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","session":{
|
||||
"render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`))
|
||||
})
|
||||
body := []byte(`{"sku":"pro_month","method":"alipay","metadata":{"is_mobile":"1","evil":"x"}}`)
|
||||
req := authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, body = %s", w.Code, w.Body)
|
||||
}
|
||||
var resp struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Session Session `json:"session"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.OrderNo != "pay001" || resp.Session.RenderType != "redirect" {
|
||||
t.Fatalf("resp = %+v", resp)
|
||||
}
|
||||
row, err := st.GetForUser(context.Background(), 1, "pay001")
|
||||
if err != nil {
|
||||
t.Fatalf("台账未落: %v", err)
|
||||
}
|
||||
if row.SKU != "pro_month" || row.BizRef != "uuid-1" || row.Status != "created" {
|
||||
t.Fatalf("台账行不符: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrder_UnknownSKU400(t *testing.T) {
|
||||
router, _ := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("不该打到 pay")
|
||||
})
|
||||
body := []byte(`{"sku":"pro_lifetime","method":"alipay"}`)
|
||||
req := authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrder_OwnershipEnforced(t *testing.T) {
|
||||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending",
|
||||
"subject":"s","amount_minor":2999,"currency":"CNY"}}`))
|
||||
})
|
||||
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 属主可查
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 1))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("owner code = %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
PayStatus string `json:"pay_status"`
|
||||
Activated bool `json:"activated"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.PayStatus != "pending" || resp.Activated {
|
||||
t.Fatalf("resp = %+v", resp)
|
||||
}
|
||||
// 他人 404
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 2))
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Fatalf("other code = %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetry_CurrencyMismatchMapped409(t *testing.T) {
|
||||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`))
|
||||
})
|
||||
_ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto")
|
||||
body := []byte(`{"method":"alipay"}`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders/pay001/retry", bytes.NewReader(body)), 1))
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("code = %d", w.Code)
|
||||
}
|
||||
var e struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
||||
if e.Code != "CURRENCY_MISMATCH" {
|
||||
t.Fatalf("code = %q, want CURRENCY_MISMATCH", e.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalog(t *testing.T) {
|
||||
router, _ := newHandlerRig(t, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/catalog", nil), 1))
|
||||
var resp struct {
|
||||
Items []CatalogItem `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp.Items) != 3 || resp.Items[0].SKU != "pro_month" {
|
||||
t.Fatalf("catalog = %+v", resp.Items)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user