Files
pangolin/server/internal/pay/handler_test.go
T
wangjia 1c2ec2844d
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 17s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 18s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 6s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 35s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 32s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 9s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m40s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 21s
feat(server/pay): 优惠套餐(Promo SKU)每账号限购一次——已付过再下单 409 PROMO_ALREADY_USED
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 11:36:24 +08:00

411 lines
15 KiB
Go

package pay
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"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.Get("/v1/pay/orders", h.List)
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", 2999, "CNY"); 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", 4990000, "USDT")
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)
}
}
// 下单时按支付方式落「展示金额 + 结算币种」(crypto→USDT 微元 / 法币→CNY 分)。
// 单 rig(rig 内 DB 复用),mock 按转发来的 method 回不同 order_no。
func TestCreateOrder_StoresDisplayAmountByMethod(t *testing.T) {
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
var fwd struct {
Method string `json:"method"`
}
_ = json.NewDecoder(r.Body).Decode(&fwd)
_, _ = w.Write([]byte(`{"data":{"order_no":"pay-` + fwd.Method + `","session":{
"render_type":"redirect","payload":{"url":"https://x"}}}}`))
})
cases := []struct {
method string
wantAmt int64
wantCurr string
}{
{"crypto", 4990000, "USDT"}, // pro_month USDT 微元
{"alipay", 2999, "CNY"}, // pro_month CNY 分
}
for _, tc := range cases {
body := []byte(`{"sku":"pro_month","method":"` + tc.method + `"}`)
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1))
if w.Code != http.StatusOK {
t.Fatalf("%s: code = %d, body = %s", tc.method, w.Code, w.Body)
}
row, err := st.GetForUser(context.Background(), 1, "pay-"+tc.method)
if err != nil {
t.Fatalf("%s: 台账未落: %v", tc.method, err)
}
if row.AmountMinor != tc.wantAmt || row.Currency != tc.wantCurr {
t.Fatalf("%s: amount=%d curr=%q, want %d/%q", tc.method, row.AmountMinor, row.Currency, tc.wantAmt, tc.wantCurr)
}
}
}
// 订单列表:只列本人、倒序、含台账富字段。
func TestList_OwnershipAndFields(t *testing.T) {
router, st := newHandlerRig(t, nil)
ctx := context.Background()
// user 1 两单(先月后年),user 2 一单(不该出现在 user 1 列表)
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "o1", "alipay", 2999, "CNY"); err != nil {
t.Fatal(err)
}
if err := st.Insert(ctx, 1, "uuid-1", "pro_year", "o2", "crypto", 34990000, "USDT"); err != nil {
t.Fatal(err)
}
if err := st.Insert(ctx, 2, "uuid-2", "pro_month", "o3", "alipay", 2999, "CNY"); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1))
if w.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", w.Code, w.Body)
}
var resp struct {
Orders []orderView `json:"orders"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Orders) != 2 {
t.Fatalf("want 2 orders for user 1, got %d: %+v", len(resp.Orders), resp.Orders)
}
for _, o := range resp.Orders {
if o.OrderNo == "o3" {
t.Fatalf("越权:看到了他人订单 o3")
}
}
// 倒序:o2(pro_year, 后插)在前,且富字段完整
first := resp.Orders[0]
if first.OrderNo != "o2" || first.SKU != "pro_year" || first.Plan != "pro" ||
first.Method != "crypto" || first.AmountMinor != 34990000 || first.Currency != "USDT" ||
first.Days != 366 || first.Status != "created" || first.CreatedAt == "" {
t.Fatalf("首单富字段不符: %+v", first)
}
}
// 展示金额兜底:老单 amount_minor=0 → 列表按档位+方式回退到展示价(不显 0)。
func TestList_ZeroAmountFallback(t *testing.T) {
router, st := newHandlerRig(t, nil)
ctx := context.Background()
// 模拟老单:amount=0、currency 空(旧下单不落金额)
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "old-alipay", "alipay", 0, ""); err != nil {
t.Fatal(err)
}
if err := st.Insert(ctx, 1, "uuid-1", "pro_year", "old-crypto", "crypto", 0, ""); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1))
var resp struct {
Orders []orderView `json:"orders"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
got := map[string]orderView{}
for _, o := range resp.Orders {
got[o.OrderNo] = o
}
// alipay → CNY 展示价 2999;crypto → USDT 展示价 34990000
if a := got["old-alipay"]; a.AmountMinor != 2999 || a.Currency != "CNY" {
t.Fatalf("alipay 兜底不符: amount=%d curr=%q, want 2999/CNY", a.AmountMinor, a.Currency)
}
if cr := got["old-crypto"]; cr.AmountMinor != 34990000 || cr.Currency != "USDT" {
t.Fatalf("crypto 兜底不符: amount=%d curr=%q, want 34990000/USDT", cr.AmountMinor, cr.Currency)
}
}
// 订单详情:回带台账富字段 + pay_status/activated。
func TestGetOrder_EnrichedFields(t *testing.T) {
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending","currency":"USDT"}}`))
})
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_year", "pay001", "crypto", 34990000, "USDT"); 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("code = %d", w.Code)
}
var resp struct {
OrderNo string `json:"order_no"`
SKU string `json:"sku"`
Plan string `json:"plan"`
Method string `json:"method"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
CreatedAt string `json:"created_at"`
PayStatus string `json:"pay_status"`
Activated bool `json:"activated"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.SKU != "pro_year" || resp.Plan != "pro" || resp.Method != "crypto" ||
resp.AmountMinor != 34990000 || resp.Currency != "USDT" || resp.CreatedAt == "" ||
resp.PayStatus != "pending" || resp.Activated {
t.Fatalf("详情富字段不符: %+v", resp)
}
}
// 优惠档(Promo SKU)限购一次:已有 paid 台账 → 下单 409 PROMO_ALREADY_USED,
// 且不打上游(fake pay client 一旦被调用即 t.Fatal)。
func TestCreateOrder_PromoAlreadyUsed409(t *testing.T) {
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("已购过优惠档,不该打到 pay 上游")
})
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month_promo", "promo-old", "alipay", 600, "CNY"); err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(context.Background(),
`UPDATE pay_purchases SET status = 'paid' WHERE out_trade_no = 'promo-old'`); err != nil {
t.Fatal(err)
}
body := []byte(`{"sku":"pro_month_promo","method":"alipay"}`)
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1))
if w.Code != http.StatusConflict {
t.Fatalf("code = %d, body = %s", w.Code, w.Body)
}
var e struct {
Code string `json:"code"`
}
if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil {
t.Fatal(err)
}
if e.Code != "PROMO_ALREADY_USED" {
t.Fatalf("code = %q, want PROMO_ALREADY_USED", e.Code)
}
}
// 优惠档历史只有 created/canceled(未 paid)→ 不受限,正常放行下单。
func TestCreateOrder_PromoNotYetPaidAllowed(t *testing.T) {
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"order_no":"promo-new","session":{
"render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`))
})
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month_promo", "promo-created", "alipay", 600, "CNY"); err != nil {
t.Fatal(err)
}
if err := st.MarkCanceled(context.Background(), 1, "promo-created"); err != nil {
t.Fatal(err)
}
body := []byte(`{"sku":"pro_month_promo","method":"alipay"}`)
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1))
if w.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", w.Code, w.Body)
}
row, err := st.GetForUser(context.Background(), 1, "promo-new")
if err != nil {
t.Fatalf("台账未落: %v", err)
}
if row.SKU != "pro_month_promo" || row.Status != "created" {
t.Fatalf("台账行不符: %+v", row)
}
}
// 非 promo SKU 即使已 paid 也不受限购约束(限购只针对 Promo==true 的 SKU)。
func TestCreateOrder_NonPromoSKUUnlimited(t *testing.T) {
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"order_no":"month-2","session":{
"render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`))
})
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "month-1", "alipay", 2999, "CNY"); err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(context.Background(),
`UPDATE pay_purchases SET status = 'paid' WHERE out_trade_no = 'month-1'`); err != nil {
t.Fatal(err)
}
body := []byte(`{"sku":"pro_month","method":"alipay"}`)
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1))
if w.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", w.Code, w.Body)
}
}
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) != 4 || resp.Items[0].SKU != "pro_month_promo" {
t.Fatalf("catalog = %+v", resp.Items)
}
// 优惠档:全员可见,promo 标记 + ¥6/$1 展示价。
promo := resp.Items[0]
if !promo.Promo || promo.PriceMinor != 600 || promo.PriceUsdtMicro != 1000000 {
t.Fatalf("promo item 不符: %+v", promo)
}
}
// 老 created 单超 TTL → 列表视图显 expired;近期 created → 仍 created。
func TestList_ExpiredWhenStale(t *testing.T) {
router, st := newHandlerRig(t, nil)
ctx := context.Background()
// 直接构造两条 created 单,改 created_at 到很久以前 / 刚刚
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "old", "alipay", 2999, "CNY"); err != nil {
t.Fatal(err)
}
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "fresh", "alipay", 2999, "CNY"); err != nil {
t.Fatal(err)
}
// 把 old 的 created_at 回拨到 TTL 之前
if _, err := st.db.ExecContext(ctx,
`UPDATE pay_purchases SET created_at = ? WHERE out_trade_no = 'old'`,
time.Now().Add(-2*orderPendingTTL).UTC()); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1))
var resp struct {
Orders []orderView `json:"orders"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
got := map[string]string{}
for _, o := range resp.Orders {
got[o.OrderNo] = o.Status
}
if got["old"] != "expired" {
t.Fatalf("old 应过期, got %q", got["old"])
}
if got["fresh"] != "created" {
t.Fatalf("fresh 应 created, got %q", got["fresh"])
}
}