feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
259
backend/internal/payment/provider/wxpay_test.go
Normal file
259
backend/internal/payment/provider/wxpay_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
//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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user