| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- //go:build unit
- package handler
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/payment"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- func TestWriteSuccessResponse(t *testing.T) {
- gin.SetMode(gin.TestMode)
- tests := []struct {
- name string
- providerKey string
- wantCode int
- wantContentType string
- wantBody string
- checkJSON bool
- wantJSONCode string
- wantJSONMessage string
- }{
- {
- name: "wxpay returns JSON with code SUCCESS",
- providerKey: "wxpay",
- wantCode: http.StatusOK,
- wantContentType: "application/json",
- checkJSON: true,
- wantJSONCode: "SUCCESS",
- wantJSONMessage: "成功",
- },
- {
- name: "stripe returns empty 200",
- providerKey: "stripe",
- wantCode: http.StatusOK,
- wantContentType: "text/plain",
- wantBody: "",
- },
- {
- name: "airwallex returns empty 200",
- providerKey: payment.TypeAirwallex,
- wantCode: http.StatusOK,
- wantContentType: "text/plain",
- wantBody: "",
- },
- {
- name: "easypay returns plain text success",
- providerKey: "easypay",
- wantCode: http.StatusOK,
- wantContentType: "text/plain",
- wantBody: "success",
- },
- {
- name: "alipay returns plain text success",
- providerKey: "alipay",
- wantCode: http.StatusOK,
- wantContentType: "text/plain",
- wantBody: "success",
- },
- {
- name: "unknown provider returns plain text success",
- providerKey: "unknown_provider",
- wantCode: http.StatusOK,
- wantContentType: "text/plain",
- wantBody: "success",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- writeSuccessResponse(c, tt.providerKey)
- assert.Equal(t, tt.wantCode, w.Code)
- assert.Contains(t, w.Header().Get("Content-Type"), tt.wantContentType)
- if tt.checkJSON {
- var resp wxpaySuccessResponse
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err, "response body should be valid JSON")
- assert.Equal(t, tt.wantJSONCode, resp.Code)
- assert.Equal(t, tt.wantJSONMessage, resp.Message)
- } else {
- assert.Equal(t, tt.wantBody, w.Body.String())
- }
- })
- }
- }
- // TestUnknownOrderWebhookAcksWithSuccess exercises the response contract that
- // handleNotify relies on when HandlePaymentNotification returns ErrOrderNotFound:
- // we still need to emit the provider-specific 2xx so the provider stops
- // retrying. We can't easily drive handleNotify end-to-end without mocking the
- // concrete *service.PaymentService, so this test locks down the two ingredients
- // the fix depends on:
- // 1. errors.Is recognises the sentinel through fmt.Errorf %w wrapping (which
- // is how service layer wraps it with the out_trade_no context).
- // 2. writeSuccessResponse produces the provider-specific body for Stripe
- // (empty 200) — matching what handleNotify calls on the ack path.
- //
- // If either contract breaks, the Stripe "unknown order → 500 loop" regresses.
- func TestUnknownOrderWebhookAcksWithSuccess(t *testing.T) {
- gin.SetMode(gin.TestMode)
- // 1) Sentinel recognition through wrapping.
- wrapped := fmt.Errorf("%w: out_trade_no=sub2_missing_42", service.ErrOrderNotFound)
- require.True(t, errors.Is(wrapped, service.ErrOrderNotFound),
- "handleNotify uses errors.Is on the wrapped service error; regression here "+
- "would mean unknown-order webhooks go back to returning 500 and looping forever")
- // A distinct error must NOT match — otherwise a DB failure would be silently
- // swallowed as an ack.
- other := errors.New("lookup order failed: connection refused")
- require.False(t, errors.Is(other, service.ErrOrderNotFound))
- // 2) Provider-specific success body is what handleNotify emits on the
- // ack path. Asserted again here because this is the shape Stripe expects
- // to consider the webhook acknowledged.
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- writeSuccessResponse(c, payment.TypeStripe)
- require.Equal(t, http.StatusOK, w.Code,
- "Stripe requires 2xx to stop retrying; anything else restarts the retry loop")
- require.Empty(t, w.Body.String(), "Stripe expects an empty body on the ack path")
- }
- func TestWebhookConstants(t *testing.T) {
- t.Run("maxWebhookBodySize is 1MB", func(t *testing.T) {
- assert.Equal(t, int64(1<<20), int64(maxWebhookBodySize))
- })
- t.Run("webhookLogTruncateLen is 200", func(t *testing.T) {
- assert.Equal(t, 200, webhookLogTruncateLen)
- })
- }
- func TestExtractOutTradeNo(t *testing.T) {
- tests := []struct {
- name string
- providerKey string
- rawBody string
- want string
- }{
- {
- name: "easypay query payload",
- providerKey: "easypay",
- rawBody: "out_trade_no=sub2_123&trade_status=TRADE_SUCCESS",
- want: "sub2_123",
- },
- {
- name: "alipay query payload",
- providerKey: "alipay",
- rawBody: "notify_time=2026-04-20+12%3A00%3A00&out_trade_no=sub2_456",
- want: "sub2_456",
- },
- {
- name: "unknown provider",
- providerKey: "wxpay",
- rawBody: "{}",
- want: "",
- },
- {
- name: "airwallex payment intent payload",
- providerKey: payment.TypeAirwallex,
- rawBody: `{"name":"payment_intent.succeeded","data":{"object":{"merchant_order_id":"sub2_awx_123"}}}`,
- want: "sub2_awx_123",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- assert.Equal(t, tt.want, extractOutTradeNo(tt.rawBody, tt.providerKey))
- })
- }
- }
- func TestVerifyNotificationWithProvidersReturnsMatchedProvider(t *testing.T) {
- firstErr := errors.New("wrong provider")
- providers := []payment.Provider{
- webhookHandlerProviderStub{
- key: payment.TypeWxpay,
- verifyErr: firstErr,
- },
- webhookHandlerProviderStub{
- key: payment.TypeWxpay,
- notification: &payment.PaymentNotification{
- OrderID: "sub2_42",
- TradeNo: "trade-42",
- Status: payment.NotificationStatusSuccess,
- },
- },
- }
- providerKey, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"})
- require.NoError(t, err)
- require.Equal(t, payment.TypeWxpay, providerKey)
- require.NotNil(t, notification)
- require.Equal(t, "sub2_42", notification.OrderID)
- }
- func TestVerifyNotificationWithProvidersFailsWhenAllProvidersReject(t *testing.T) {
- providers := []payment.Provider{
- webhookHandlerProviderStub{
- key: payment.TypeWxpay,
- verifyErr: errors.New("verify failed a"),
- },
- webhookHandlerProviderStub{
- key: payment.TypeWxpay,
- verifyErr: errors.New("verify failed b"),
- },
- }
- _, _, err := verifyNotificationWithProviders(context.Background(), providers, "{}", nil)
- require.Error(t, err)
- }
- type webhookHandlerProviderStub struct {
- key string
- notification *payment.PaymentNotification
- verifyErr error
- }
- func (p webhookHandlerProviderStub) Name() string { return p.key }
- func (p webhookHandlerProviderStub) ProviderKey() string { return p.key }
- func (p webhookHandlerProviderStub) SupportedTypes() []payment.PaymentType {
- return []payment.PaymentType{payment.PaymentType(p.key)}
- }
- func (p webhookHandlerProviderStub) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
- panic("unexpected call")
- }
- func (p webhookHandlerProviderStub) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) {
- panic("unexpected call")
- }
- func (p webhookHandlerProviderStub) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) {
- if p.verifyErr != nil {
- return nil, p.verifyErr
- }
- return p.notification, nil
- }
- func (p webhookHandlerProviderStub) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
- panic("unexpected call")
- }
|