payment_webhook_handler_test.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. //go:build unit
  2. package handler
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "net/http"
  9. "net/http/httptest"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/payment"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/assert"
  15. "github.com/stretchr/testify/require"
  16. )
  17. func TestWriteSuccessResponse(t *testing.T) {
  18. gin.SetMode(gin.TestMode)
  19. tests := []struct {
  20. name string
  21. providerKey string
  22. wantCode int
  23. wantContentType string
  24. wantBody string
  25. checkJSON bool
  26. wantJSONCode string
  27. wantJSONMessage string
  28. }{
  29. {
  30. name: "wxpay returns JSON with code SUCCESS",
  31. providerKey: "wxpay",
  32. wantCode: http.StatusOK,
  33. wantContentType: "application/json",
  34. checkJSON: true,
  35. wantJSONCode: "SUCCESS",
  36. wantJSONMessage: "成功",
  37. },
  38. {
  39. name: "stripe returns empty 200",
  40. providerKey: "stripe",
  41. wantCode: http.StatusOK,
  42. wantContentType: "text/plain",
  43. wantBody: "",
  44. },
  45. {
  46. name: "airwallex returns empty 200",
  47. providerKey: payment.TypeAirwallex,
  48. wantCode: http.StatusOK,
  49. wantContentType: "text/plain",
  50. wantBody: "",
  51. },
  52. {
  53. name: "easypay returns plain text success",
  54. providerKey: "easypay",
  55. wantCode: http.StatusOK,
  56. wantContentType: "text/plain",
  57. wantBody: "success",
  58. },
  59. {
  60. name: "alipay returns plain text success",
  61. providerKey: "alipay",
  62. wantCode: http.StatusOK,
  63. wantContentType: "text/plain",
  64. wantBody: "success",
  65. },
  66. {
  67. name: "unknown provider returns plain text success",
  68. providerKey: "unknown_provider",
  69. wantCode: http.StatusOK,
  70. wantContentType: "text/plain",
  71. wantBody: "success",
  72. },
  73. }
  74. for _, tt := range tests {
  75. t.Run(tt.name, func(t *testing.T) {
  76. w := httptest.NewRecorder()
  77. c, _ := gin.CreateTestContext(w)
  78. writeSuccessResponse(c, tt.providerKey)
  79. assert.Equal(t, tt.wantCode, w.Code)
  80. assert.Contains(t, w.Header().Get("Content-Type"), tt.wantContentType)
  81. if tt.checkJSON {
  82. var resp wxpaySuccessResponse
  83. err := json.Unmarshal(w.Body.Bytes(), &resp)
  84. require.NoError(t, err, "response body should be valid JSON")
  85. assert.Equal(t, tt.wantJSONCode, resp.Code)
  86. assert.Equal(t, tt.wantJSONMessage, resp.Message)
  87. } else {
  88. assert.Equal(t, tt.wantBody, w.Body.String())
  89. }
  90. })
  91. }
  92. }
  93. // TestUnknownOrderWebhookAcksWithSuccess exercises the response contract that
  94. // handleNotify relies on when HandlePaymentNotification returns ErrOrderNotFound:
  95. // we still need to emit the provider-specific 2xx so the provider stops
  96. // retrying. We can't easily drive handleNotify end-to-end without mocking the
  97. // concrete *service.PaymentService, so this test locks down the two ingredients
  98. // the fix depends on:
  99. // 1. errors.Is recognises the sentinel through fmt.Errorf %w wrapping (which
  100. // is how service layer wraps it with the out_trade_no context).
  101. // 2. writeSuccessResponse produces the provider-specific body for Stripe
  102. // (empty 200) — matching what handleNotify calls on the ack path.
  103. //
  104. // If either contract breaks, the Stripe "unknown order → 500 loop" regresses.
  105. func TestUnknownOrderWebhookAcksWithSuccess(t *testing.T) {
  106. gin.SetMode(gin.TestMode)
  107. // 1) Sentinel recognition through wrapping.
  108. wrapped := fmt.Errorf("%w: out_trade_no=sub2_missing_42", service.ErrOrderNotFound)
  109. require.True(t, errors.Is(wrapped, service.ErrOrderNotFound),
  110. "handleNotify uses errors.Is on the wrapped service error; regression here "+
  111. "would mean unknown-order webhooks go back to returning 500 and looping forever")
  112. // A distinct error must NOT match — otherwise a DB failure would be silently
  113. // swallowed as an ack.
  114. other := errors.New("lookup order failed: connection refused")
  115. require.False(t, errors.Is(other, service.ErrOrderNotFound))
  116. // 2) Provider-specific success body is what handleNotify emits on the
  117. // ack path. Asserted again here because this is the shape Stripe expects
  118. // to consider the webhook acknowledged.
  119. w := httptest.NewRecorder()
  120. c, _ := gin.CreateTestContext(w)
  121. writeSuccessResponse(c, payment.TypeStripe)
  122. require.Equal(t, http.StatusOK, w.Code,
  123. "Stripe requires 2xx to stop retrying; anything else restarts the retry loop")
  124. require.Empty(t, w.Body.String(), "Stripe expects an empty body on the ack path")
  125. }
  126. func TestWebhookConstants(t *testing.T) {
  127. t.Run("maxWebhookBodySize is 1MB", func(t *testing.T) {
  128. assert.Equal(t, int64(1<<20), int64(maxWebhookBodySize))
  129. })
  130. t.Run("webhookLogTruncateLen is 200", func(t *testing.T) {
  131. assert.Equal(t, 200, webhookLogTruncateLen)
  132. })
  133. }
  134. func TestExtractOutTradeNo(t *testing.T) {
  135. tests := []struct {
  136. name string
  137. providerKey string
  138. rawBody string
  139. want string
  140. }{
  141. {
  142. name: "easypay query payload",
  143. providerKey: "easypay",
  144. rawBody: "out_trade_no=sub2_123&trade_status=TRADE_SUCCESS",
  145. want: "sub2_123",
  146. },
  147. {
  148. name: "alipay query payload",
  149. providerKey: "alipay",
  150. rawBody: "notify_time=2026-04-20+12%3A00%3A00&out_trade_no=sub2_456",
  151. want: "sub2_456",
  152. },
  153. {
  154. name: "unknown provider",
  155. providerKey: "wxpay",
  156. rawBody: "{}",
  157. want: "",
  158. },
  159. {
  160. name: "airwallex payment intent payload",
  161. providerKey: payment.TypeAirwallex,
  162. rawBody: `{"name":"payment_intent.succeeded","data":{"object":{"merchant_order_id":"sub2_awx_123"}}}`,
  163. want: "sub2_awx_123",
  164. },
  165. }
  166. for _, tt := range tests {
  167. t.Run(tt.name, func(t *testing.T) {
  168. assert.Equal(t, tt.want, extractOutTradeNo(tt.rawBody, tt.providerKey))
  169. })
  170. }
  171. }
  172. func TestVerifyNotificationWithProvidersReturnsMatchedProvider(t *testing.T) {
  173. firstErr := errors.New("wrong provider")
  174. providers := []payment.Provider{
  175. webhookHandlerProviderStub{
  176. key: payment.TypeWxpay,
  177. verifyErr: firstErr,
  178. },
  179. webhookHandlerProviderStub{
  180. key: payment.TypeWxpay,
  181. notification: &payment.PaymentNotification{
  182. OrderID: "sub2_42",
  183. TradeNo: "trade-42",
  184. Status: payment.NotificationStatusSuccess,
  185. },
  186. },
  187. }
  188. providerKey, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"})
  189. require.NoError(t, err)
  190. require.Equal(t, payment.TypeWxpay, providerKey)
  191. require.NotNil(t, notification)
  192. require.Equal(t, "sub2_42", notification.OrderID)
  193. }
  194. func TestVerifyNotificationWithProvidersFailsWhenAllProvidersReject(t *testing.T) {
  195. providers := []payment.Provider{
  196. webhookHandlerProviderStub{
  197. key: payment.TypeWxpay,
  198. verifyErr: errors.New("verify failed a"),
  199. },
  200. webhookHandlerProviderStub{
  201. key: payment.TypeWxpay,
  202. verifyErr: errors.New("verify failed b"),
  203. },
  204. }
  205. _, _, err := verifyNotificationWithProviders(context.Background(), providers, "{}", nil)
  206. require.Error(t, err)
  207. }
  208. type webhookHandlerProviderStub struct {
  209. key string
  210. notification *payment.PaymentNotification
  211. verifyErr error
  212. }
  213. func (p webhookHandlerProviderStub) Name() string { return p.key }
  214. func (p webhookHandlerProviderStub) ProviderKey() string { return p.key }
  215. func (p webhookHandlerProviderStub) SupportedTypes() []payment.PaymentType {
  216. return []payment.PaymentType{payment.PaymentType(p.key)}
  217. }
  218. func (p webhookHandlerProviderStub) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
  219. panic("unexpected call")
  220. }
  221. func (p webhookHandlerProviderStub) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) {
  222. panic("unexpected call")
  223. }
  224. func (p webhookHandlerProviderStub) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) {
  225. if p.verifyErr != nil {
  226. return nil, p.verifyErr
  227. }
  228. return p.notification, nil
  229. }
  230. func (p webhookHandlerProviderStub) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
  231. panic("unexpected call")
  232. }