Backend fixes: - #1: doSub subscription idempotency via audit log check - #2: markFailed only when status=RECHARGING (prevents overwriting COMPLETED) - #3: ExpireTimedOutOrders checks upstream payment before expiring - #4: Public verify endpoint for payment result page (no auth required) - #5: EasyPay QueryOrder returns amount, confirmPayment handles zero amount - #6: WxPay notifyUrl priority: request-first, config-fallback - #7: EasyPay remove double URL decode in VerifyNotification - #8: checkPaid/cancelUpstreamPayment use order's provider instance - #9: Amount NaN/Inf/negative validation in order creation and refund - #10: Refund amount comparison uses tolerance instead of float64 == - #11: Skip balance deduction on retry when previous rollback failed - #12: checkPaid logs fulfillment errors instead of silently ignoring - #13: WxPay certSerial added to required config fields Frontend fixes: - Payment result page no longer requires authentication - Public verify API fallback for expired sessions
260 lines
5.9 KiB
Go
260 lines
5.9 KiB
Go
//go:build unit
|
|
|
|
package provider
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
)
|
|
|
|
func TestMapWxState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "SUCCESS maps to paid",
|
|
input: wxpayTradeStateSuccess,
|
|
want: payment.ProviderStatusPaid,
|
|
},
|
|
{
|
|
name: "REFUND maps to refunded",
|
|
input: wxpayTradeStateRefund,
|
|
want: payment.ProviderStatusRefunded,
|
|
},
|
|
{
|
|
name: "CLOSED maps to failed",
|
|
input: wxpayTradeStateClosed,
|
|
want: payment.ProviderStatusFailed,
|
|
},
|
|
{
|
|
name: "PAYERROR maps to failed",
|
|
input: wxpayTradeStatePayError,
|
|
want: payment.ProviderStatusFailed,
|
|
},
|
|
{
|
|
name: "unknown state maps to pending",
|
|
input: "NOTPAY",
|
|
want: payment.ProviderStatusPending,
|
|
},
|
|
{
|
|
name: "empty string maps to pending",
|
|
input: "",
|
|
want: payment.ProviderStatusPending,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := mapWxState(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("mapWxState(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWxSV(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input *string
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil pointer returns empty string",
|
|
input: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "non-nil pointer returns value",
|
|
input: strPtr("hello"),
|
|
want: "hello",
|
|
},
|
|
{
|
|
name: "pointer to empty string returns empty string",
|
|
input: strPtr(""),
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := wxSV(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("wxSV() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
func TestFormatPEM(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
keyType string
|
|
want string
|
|
}{
|
|
{
|
|
name: "raw key gets wrapped with headers",
|
|
key: "MIIBIjANBgkqhki...",
|
|
keyType: "PUBLIC KEY",
|
|
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
|
},
|
|
{
|
|
name: "already formatted key is returned as-is",
|
|
key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
|
keyType: "PRIVATE KEY",
|
|
want: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
|
},
|
|
{
|
|
name: "key with leading/trailing whitespace is trimmed before check",
|
|
key: " \n MIIBIjANBgkqhki... \n ",
|
|
keyType: "PUBLIC KEY",
|
|
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
|
},
|
|
{
|
|
name: "already formatted key with whitespace is trimmed and returned",
|
|
key: " -----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY----- ",
|
|
keyType: "RSA PRIVATE KEY",
|
|
want: "-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := formatPEM(tt.key, tt.keyType)
|
|
if got != tt.want {
|
|
t.Errorf("formatPEM(%q, %q) =\n%s\nwant:\n%s", tt.key, tt.keyType, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewWxpay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
validConfig := map[string]string{
|
|
"appId": "wx1234567890",
|
|
"mchId": "1234567890",
|
|
"privateKey": "fake-private-key",
|
|
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
|
|
"publicKey": "fake-public-key",
|
|
"publicKeyId": "key-id-001",
|
|
"certSerial": "SERIAL001",
|
|
}
|
|
|
|
// helper to clone and override config fields
|
|
withOverride := func(overrides map[string]string) map[string]string {
|
|
cfg := make(map[string]string, len(validConfig))
|
|
for k, v := range validConfig {
|
|
cfg[k] = v
|
|
}
|
|
for k, v := range overrides {
|
|
cfg[k] = v
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
config map[string]string
|
|
wantErr bool
|
|
errSubstr string
|
|
}{
|
|
{
|
|
name: "valid config succeeds",
|
|
config: validConfig,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "missing appId",
|
|
config: withOverride(map[string]string{"appId": ""}),
|
|
wantErr: true,
|
|
errSubstr: "appId",
|
|
},
|
|
{
|
|
name: "missing mchId",
|
|
config: withOverride(map[string]string{"mchId": ""}),
|
|
wantErr: true,
|
|
errSubstr: "mchId",
|
|
},
|
|
{
|
|
name: "missing privateKey",
|
|
config: withOverride(map[string]string{"privateKey": ""}),
|
|
wantErr: true,
|
|
errSubstr: "privateKey",
|
|
},
|
|
{
|
|
name: "missing apiV3Key",
|
|
config: withOverride(map[string]string{"apiV3Key": ""}),
|
|
wantErr: true,
|
|
errSubstr: "apiV3Key",
|
|
},
|
|
{
|
|
name: "missing publicKey",
|
|
config: withOverride(map[string]string{"publicKey": ""}),
|
|
wantErr: true,
|
|
errSubstr: "publicKey",
|
|
},
|
|
{
|
|
name: "missing publicKeyId",
|
|
config: withOverride(map[string]string{"publicKeyId": ""}),
|
|
wantErr: true,
|
|
errSubstr: "publicKeyId",
|
|
},
|
|
{
|
|
name: "apiV3Key too short",
|
|
config: withOverride(map[string]string{"apiV3Key": "short"}),
|
|
wantErr: true,
|
|
errSubstr: "exactly 32 bytes",
|
|
},
|
|
{
|
|
name: "apiV3Key too long",
|
|
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
|
|
wantErr: true,
|
|
errSubstr: "exactly 32 bytes",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := NewWxpay("test-instance", tt.config)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
|
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected non-nil Wxpay instance")
|
|
}
|
|
if got.instanceID != "test-instance" {
|
|
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
|
|
}
|
|
})
|
|
}
|
|
}
|