mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 23:53:33 +08:00
Order query:
- 彩虹易支付 and ZPay read act=order parameters from the URL query only;
a body-only request is answered with "No Act!", so order status queries
(reconcile, payment status checks) never reached the order. Parameters
are now sent in the URL query and mirrored in the form body for forks
that read the body.
- Decode status/money/trade_no leniently: PHP deployments commonly return
integers as strings ("status":"1"), which previously failed the whole
response decode. JSON null falls back to the nested data object.
- Treat a non-success code as a query error instead of an unpaid order,
and require an echoed out_trade_no to match the queried order.
- Strip the request URL (which carries the merchant key) from transport
errors before they are returned.
Notifications:
- Add payment.NotificationConfirmer. After a notification is verified, the
webhook asks the same provider instance to confirm it; EasyPay queries
the upstream order and requires it to be paid with a matching amount and
trade_no (a missing notify trade_no is filled from the query). Failures
are recorded as PAYMENT_UPSTREAM_UNCONFIRMED and answered with 502 so the
gateway retries; the order stays pending and reconcile still applies.
- EasyPay notifications must carry the instance's configured pid, and
field values other than name must be plain tokens.
- Return URLs must use a plain hostname or IP literal without userinfo.
256 lines
7.9 KiB
Go
256 lines
7.9 KiB
Go
//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,
|
|
},
|
|
},
|
|
}
|
|
|
|
provider, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, payment.TypeWxpay, provider.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")
|
|
}
|