payment_handler_resume_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. //go:build unit
  2. package handler
  3. import (
  4. "bytes"
  5. "context"
  6. "database/sql"
  7. "encoding/json"
  8. "net/http"
  9. "net/http/httptest"
  10. "testing"
  11. "time"
  12. dbent "github.com/Wei-Shaw/sub2api/ent"
  13. "github.com/Wei-Shaw/sub2api/ent/enttest"
  14. "github.com/Wei-Shaw/sub2api/internal/payment"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/gin-gonic/gin"
  17. "github.com/stretchr/testify/require"
  18. "entgo.io/ent/dialect"
  19. entsql "entgo.io/ent/dialect/sql"
  20. _ "modernc.org/sqlite"
  21. )
  22. func TestApplyWeChatPaymentResumeClaims(t *testing.T) {
  23. t.Parallel()
  24. req := CreateOrderRequest{
  25. Amount: 0,
  26. PaymentType: payment.TypeWxpay,
  27. OrderType: payment.OrderTypeBalance,
  28. }
  29. err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
  30. OpenID: "openid-123",
  31. PaymentType: payment.TypeWxpay,
  32. Amount: "12.50",
  33. OrderType: payment.OrderTypeSubscription,
  34. PlanID: 7,
  35. })
  36. if err != nil {
  37. t.Fatalf("applyWeChatPaymentResumeClaims returned error: %v", err)
  38. }
  39. if req.OpenID != "openid-123" {
  40. t.Fatalf("openid = %q, want %q", req.OpenID, "openid-123")
  41. }
  42. if req.Amount != 12.5 {
  43. t.Fatalf("amount = %v, want 12.5", req.Amount)
  44. }
  45. if req.OrderType != payment.OrderTypeSubscription {
  46. t.Fatalf("order_type = %q, want %q", req.OrderType, payment.OrderTypeSubscription)
  47. }
  48. if req.PlanID != 7 {
  49. t.Fatalf("plan_id = %d, want 7", req.PlanID)
  50. }
  51. }
  52. func TestApplyWeChatPaymentResumeClaimsRejectsPaymentTypeMismatch(t *testing.T) {
  53. t.Parallel()
  54. req := CreateOrderRequest{
  55. PaymentType: payment.TypeAlipay,
  56. }
  57. err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
  58. OpenID: "openid-123",
  59. PaymentType: payment.TypeWxpay,
  60. Amount: "12.50",
  61. OrderType: payment.OrderTypeBalance,
  62. })
  63. if err == nil {
  64. t.Fatal("applyWeChatPaymentResumeClaims should reject mismatched payment types")
  65. }
  66. }
  67. func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
  68. t.Parallel()
  69. gin.SetMode(gin.TestMode)
  70. db, err := sql.Open("sqlite", "file:payment_handler_public_verify?mode=memory&cache=shared")
  71. require.NoError(t, err)
  72. t.Cleanup(func() { _ = db.Close() })
  73. _, err = db.Exec("PRAGMA foreign_keys = ON")
  74. require.NoError(t, err)
  75. drv := entsql.OpenDB(dialect.SQLite, db)
  76. client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
  77. t.Cleanup(func() { _ = client.Close() })
  78. user, err := client.User.Create().
  79. SetEmail("public-verify@example.com").
  80. SetPasswordHash("hash").
  81. SetUsername("public-verify-user").
  82. Save(context.Background())
  83. require.NoError(t, err)
  84. order, err := client.PaymentOrder.Create().
  85. SetUserID(user.ID).
  86. SetUserEmail(user.Email).
  87. SetUserName(user.Username).
  88. SetAmount(88).
  89. SetPayAmount(90.64).
  90. SetFeeRate(0.03).
  91. SetRechargeCode("PUBLIC-VERIFY").
  92. SetOutTradeNo("legacy-order-no").
  93. SetPaymentType(payment.TypeAlipay).
  94. SetPaymentTradeNo("trade-public-verify").
  95. SetOrderType(payment.OrderTypeBalance).
  96. SetStatus(service.OrderStatusPending).
  97. SetExpiresAt(time.Now().Add(time.Hour)).
  98. SetClientIP("127.0.0.1").
  99. SetSrcHost("api.example.com").
  100. SetProviderSnapshot(map[string]any{"currency": "HKD"}).
  101. Save(context.Background())
  102. require.NoError(t, err)
  103. paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, nil, nil, nil, nil)
  104. h := NewPaymentHandler(paymentSvc, nil)
  105. recorder := httptest.NewRecorder()
  106. ctx, _ := gin.CreateTestContext(recorder)
  107. ctx.Request = httptest.NewRequest(
  108. http.MethodPost,
  109. "/api/v1/payment/public/orders/verify",
  110. bytes.NewBufferString(`{"out_trade_no":"legacy-order-no"}`),
  111. )
  112. ctx.Request.Header.Set("Content-Type", "application/json")
  113. h.VerifyOrderPublic(ctx)
  114. require.Equal(t, http.StatusOK, recorder.Code)
  115. var resp struct {
  116. Code int `json:"code"`
  117. Data map[string]any `json:"data"`
  118. }
  119. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  120. require.Equal(t, 0, resp.Code)
  121. require.Equal(t, "legacy-order-no", resp.Data["out_trade_no"])
  122. require.Equal(t, service.OrderStatusPending, resp.Data["status"])
  123. require.Equal(t, false, resp.Data["paid"])
  124. require.NotEmpty(t, resp.Data["created_at"])
  125. require.NotEmpty(t, resp.Data["expires_at"])
  126. for _, field := range []string{
  127. "id",
  128. "amount",
  129. "pay_amount",
  130. "fee_rate",
  131. "currency",
  132. "payment_type",
  133. "order_type",
  134. "refund_amount",
  135. "refund_reason",
  136. "refund_requested_at",
  137. "refund_requested_by",
  138. "refund_request_reason",
  139. "plan_id",
  140. } {
  141. require.NotContains(t, resp.Data, field)
  142. }
  143. require.NotZero(t, order.ID)
  144. }
  145. func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing.T) {
  146. gin.SetMode(gin.TestMode)
  147. t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "0123456789abcdef0123456789abcdef")
  148. db, err := sql.Open("sqlite", "file:payment_handler_public_resolve?mode=memory&cache=shared")
  149. require.NoError(t, err)
  150. t.Cleanup(func() { _ = db.Close() })
  151. _, err = db.Exec("PRAGMA foreign_keys = ON")
  152. require.NoError(t, err)
  153. drv := entsql.OpenDB(dialect.SQLite, db)
  154. client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
  155. t.Cleanup(func() { _ = client.Close() })
  156. user, err := client.User.Create().
  157. SetEmail("public-resolve@example.com").
  158. SetPasswordHash("hash").
  159. SetUsername("public-resolve-user").
  160. Save(context.Background())
  161. require.NoError(t, err)
  162. order, err := client.PaymentOrder.Create().
  163. SetUserID(user.ID).
  164. SetUserEmail(user.Email).
  165. SetUserName(user.Username).
  166. SetAmount(100).
  167. SetPayAmount(103).
  168. SetFeeRate(0.03).
  169. SetRechargeCode("PUBLIC-RESOLVE").
  170. SetOutTradeNo("resolve-order-no").
  171. SetPaymentType(payment.TypeAlipay).
  172. SetPaymentTradeNo("trade-public-resolve").
  173. SetOrderType(payment.OrderTypeBalance).
  174. SetStatus(service.OrderStatusPaid).
  175. SetExpiresAt(time.Now().Add(time.Hour)).
  176. SetPaidAt(time.Now()).
  177. SetClientIP("127.0.0.1").
  178. SetSrcHost("api.example.com").
  179. SetProviderSnapshot(map[string]any{"currency": "USD"}).
  180. Save(context.Background())
  181. require.NoError(t, err)
  182. resumeSvc := service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
  183. token, err := resumeSvc.CreateToken(service.ResumeTokenClaims{
  184. OrderID: order.ID,
  185. UserID: user.ID,
  186. PaymentType: payment.TypeAlipay,
  187. CanonicalReturnURL: "https://app.example.com/payment/result",
  188. })
  189. require.NoError(t, err)
  190. configSvc := service.NewPaymentConfigService(client, nil, []byte("0123456789abcdef0123456789abcdef"))
  191. paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, configSvc, nil, nil, nil)
  192. h := NewPaymentHandler(paymentSvc, nil)
  193. recorder := httptest.NewRecorder()
  194. ctx, _ := gin.CreateTestContext(recorder)
  195. ctx.Request = httptest.NewRequest(
  196. http.MethodPost,
  197. "/api/v1/payment/public/orders/resolve",
  198. bytes.NewBufferString(`{"resume_token":"`+token+`"}`),
  199. )
  200. ctx.Request.Header.Set("Content-Type", "application/json")
  201. h.ResolveOrderPublicByResumeToken(ctx)
  202. require.Equal(t, http.StatusOK, recorder.Code)
  203. var resp struct {
  204. Code int `json:"code"`
  205. Data map[string]any `json:"data"`
  206. }
  207. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  208. require.Equal(t, 0, resp.Code)
  209. require.Equal(t, float64(order.ID), resp.Data["id"])
  210. require.Equal(t, "resolve-order-no", resp.Data["out_trade_no"])
  211. require.Equal(t, 100.0, resp.Data["amount"])
  212. require.Equal(t, 103.0, resp.Data["pay_amount"])
  213. require.Equal(t, 0.03, resp.Data["fee_rate"])
  214. require.Equal(t, "USD", resp.Data["currency"])
  215. require.Equal(t, payment.TypeAlipay, resp.Data["payment_type"])
  216. require.Equal(t, payment.OrderTypeBalance, resp.Data["order_type"])
  217. require.Equal(t, service.OrderStatusPaid, resp.Data["status"])
  218. require.Contains(t, resp.Data, "created_at")
  219. require.Contains(t, resp.Data, "expires_at")
  220. require.Contains(t, resp.Data, "refund_amount")
  221. }
  222. func TestResolveOrderPublicByResumeTokenReturnsBadRequestForMismatchedToken(t *testing.T) {
  223. gin.SetMode(gin.TestMode)
  224. t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "0123456789abcdef0123456789abcdef")
  225. db, err := sql.Open("sqlite", "file:payment_handler_public_resolve_mismatch?mode=memory&cache=shared")
  226. require.NoError(t, err)
  227. t.Cleanup(func() { _ = db.Close() })
  228. _, err = db.Exec("PRAGMA foreign_keys = ON")
  229. require.NoError(t, err)
  230. drv := entsql.OpenDB(dialect.SQLite, db)
  231. client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
  232. t.Cleanup(func() { _ = client.Close() })
  233. user, err := client.User.Create().
  234. SetEmail("public-resolve-mismatch@example.com").
  235. SetPasswordHash("hash").
  236. SetUsername("public-resolve-mismatch-user").
  237. Save(context.Background())
  238. require.NoError(t, err)
  239. order, err := client.PaymentOrder.Create().
  240. SetUserID(user.ID).
  241. SetUserEmail(user.Email).
  242. SetUserName(user.Username).
  243. SetAmount(100).
  244. SetPayAmount(103).
  245. SetFeeRate(0.03).
  246. SetRechargeCode("PUBLIC-RESOLVE-MISMATCH").
  247. SetOutTradeNo("resolve-order-mismatch-no").
  248. SetPaymentType(payment.TypeAlipay).
  249. SetPaymentTradeNo("trade-public-resolve-mismatch").
  250. SetOrderType(payment.OrderTypeBalance).
  251. SetStatus(service.OrderStatusPaid).
  252. SetExpiresAt(time.Now().Add(time.Hour)).
  253. SetPaidAt(time.Now()).
  254. SetClientIP("127.0.0.1").
  255. SetSrcHost("api.example.com").
  256. Save(context.Background())
  257. require.NoError(t, err)
  258. resumeSvc := service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
  259. token, err := resumeSvc.CreateToken(service.ResumeTokenClaims{
  260. OrderID: order.ID,
  261. UserID: user.ID + 999,
  262. PaymentType: payment.TypeAlipay,
  263. CanonicalReturnURL: "https://app.example.com/payment/result",
  264. })
  265. require.NoError(t, err)
  266. configSvc := service.NewPaymentConfigService(client, nil, []byte("0123456789abcdef0123456789abcdef"))
  267. paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, configSvc, nil, nil, nil)
  268. h := NewPaymentHandler(paymentSvc, nil)
  269. recorder := httptest.NewRecorder()
  270. ctx, _ := gin.CreateTestContext(recorder)
  271. ctx.Request = httptest.NewRequest(
  272. http.MethodPost,
  273. "/api/v1/payment/public/orders/resolve",
  274. bytes.NewBufferString(`{"resume_token":"`+token+`"}`),
  275. )
  276. ctx.Request.Header.Set("Content-Type", "application/json")
  277. h.ResolveOrderPublicByResumeToken(ctx)
  278. require.Equal(t, http.StatusBadRequest, recorder.Code)
  279. var resp struct {
  280. Code int `json:"code"`
  281. Reason string `json:"reason"`
  282. Message string `json:"message"`
  283. }
  284. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  285. require.Equal(t, http.StatusBadRequest, resp.Code)
  286. require.Equal(t, "INVALID_RESUME_TOKEN", resp.Reason)
  287. }
  288. func TestVerifyOrderPublicRejectsBlankOutTradeNo(t *testing.T) {
  289. gin.SetMode(gin.TestMode)
  290. db, err := sql.Open("sqlite", "file:payment_handler_public_verify_blank?mode=memory&cache=shared")
  291. require.NoError(t, err)
  292. t.Cleanup(func() { _ = db.Close() })
  293. _, err = db.Exec("PRAGMA foreign_keys = ON")
  294. require.NoError(t, err)
  295. drv := entsql.OpenDB(dialect.SQLite, db)
  296. client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
  297. t.Cleanup(func() { _ = client.Close() })
  298. paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, nil, nil, nil, nil)
  299. h := NewPaymentHandler(paymentSvc, nil)
  300. recorder := httptest.NewRecorder()
  301. ctx, _ := gin.CreateTestContext(recorder)
  302. ctx.Request = httptest.NewRequest(
  303. http.MethodPost,
  304. "/api/v1/payment/public/orders/verify",
  305. bytes.NewBufferString(`{"out_trade_no":" "}`),
  306. )
  307. ctx.Request.Header.Set("Content-Type", "application/json")
  308. h.VerifyOrderPublic(ctx)
  309. require.Equal(t, http.StatusBadRequest, recorder.Code)
  310. var resp struct {
  311. Code int `json:"code"`
  312. Reason string `json:"reason"`
  313. }
  314. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  315. require.Equal(t, http.StatusBadRequest, resp.Code)
  316. require.Equal(t, "INVALID_OUT_TRADE_NO", resp.Reason)
  317. }