369 lines
11 KiB
Go
369 lines
11 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"testing"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
type paymentFulfillmentTestProvider struct {
|
|
key string
|
|
supportedTypes []payment.PaymentType
|
|
}
|
|
|
|
func (p paymentFulfillmentTestProvider) Name() string { return p.key }
|
|
func (p paymentFulfillmentTestProvider) ProviderKey() string { return p.key }
|
|
func (p paymentFulfillmentTestProvider) SupportedTypes() []payment.PaymentType {
|
|
return p.supportedTypes
|
|
}
|
|
func (p paymentFulfillmentTestProvider) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
|
panic("unexpected call")
|
|
}
|
|
func (p paymentFulfillmentTestProvider) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
|
panic("unexpected call")
|
|
}
|
|
func (p paymentFulfillmentTestProvider) VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
|
|
panic("unexpected call")
|
|
}
|
|
func (p paymentFulfillmentTestProvider) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
|
panic("unexpected call")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveRedeemAction — pure idempotency decision logic
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolveRedeemAction_CodeNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
action := resolveRedeemAction(nil, nil)
|
|
assert.Equal(t, redeemActionCreate, action, "nil code with nil error should create")
|
|
}
|
|
|
|
func TestResolveRedeemAction_LookupError(t *testing.T) {
|
|
t.Parallel()
|
|
action := resolveRedeemAction(nil, errors.New("db connection lost"))
|
|
assert.Equal(t, redeemActionCreate, action, "lookup error should fall back to create")
|
|
}
|
|
|
|
func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) {
|
|
t.Parallel()
|
|
// Edge case: both code and error are non-nil (shouldn't happen in practice,
|
|
// but the function should still treat error as authoritative)
|
|
code := &RedeemCode{Status: StatusUnused}
|
|
action := resolveRedeemAction(code, errors.New("partial error"))
|
|
assert.Equal(t, redeemActionCreate, action, "non-nil error should always result in create regardless of code")
|
|
}
|
|
|
|
func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) {
|
|
t.Parallel()
|
|
code := &RedeemCode{
|
|
Code: "test-code-123",
|
|
Status: StatusUsed,
|
|
Type: RedeemTypeBalance,
|
|
Value: 10.0,
|
|
}
|
|
action := resolveRedeemAction(code, nil)
|
|
assert.Equal(t, redeemActionSkipCompleted, action, "used code should skip to completed")
|
|
}
|
|
|
|
func TestResolveRedeemAction_CodeExistsAndUnused(t *testing.T) {
|
|
t.Parallel()
|
|
code := &RedeemCode{
|
|
Code: "test-code-456",
|
|
Status: StatusUnused,
|
|
Type: RedeemTypeBalance,
|
|
Value: 25.0,
|
|
}
|
|
action := resolveRedeemAction(code, nil)
|
|
assert.Equal(t, redeemActionRedeem, action, "unused code should skip creation and proceed to redeem")
|
|
}
|
|
|
|
func TestResolveRedeemAction_CodeExistsWithExpiredStatus(t *testing.T) {
|
|
t.Parallel()
|
|
// A code with a non-standard status (neither "unused" nor "used")
|
|
// should NOT be treated as used, so it falls through to redeemActionRedeem.
|
|
code := &RedeemCode{
|
|
Code: "expired-code",
|
|
Status: StatusExpired,
|
|
}
|
|
action := resolveRedeemAction(code, nil)
|
|
assert.Equal(t, redeemActionRedeem, action, "expired-status code is not IsUsed(), should redeem")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Table-driven comprehensive test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolveRedeemAction_Table(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
code *RedeemCode
|
|
err error
|
|
expected redeemAction
|
|
}{
|
|
{
|
|
name: "nil code, nil error — first run",
|
|
code: nil,
|
|
err: nil,
|
|
expected: redeemActionCreate,
|
|
},
|
|
{
|
|
name: "nil code, lookup error — treat as not found",
|
|
code: nil,
|
|
err: ErrRedeemCodeNotFound,
|
|
expected: redeemActionCreate,
|
|
},
|
|
{
|
|
name: "nil code, generic DB error — treat as not found",
|
|
code: nil,
|
|
err: errors.New("connection refused"),
|
|
expected: redeemActionCreate,
|
|
},
|
|
{
|
|
name: "code exists, used — previous run completed redeem",
|
|
code: &RedeemCode{Status: StatusUsed},
|
|
err: nil,
|
|
expected: redeemActionSkipCompleted,
|
|
},
|
|
{
|
|
name: "code exists, unused — previous run created code but crashed before redeem",
|
|
code: &RedeemCode{Status: StatusUnused},
|
|
err: nil,
|
|
expected: redeemActionRedeem,
|
|
},
|
|
{
|
|
name: "code exists but error also set — error takes precedence",
|
|
code: &RedeemCode{Status: StatusUsed},
|
|
err: errors.New("unexpected"),
|
|
expected: redeemActionCreate,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := resolveRedeemAction(tt.code, tt.err)
|
|
assert.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// redeemAction enum value sanity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRedeemAction_DistinctValues(t *testing.T) {
|
|
t.Parallel()
|
|
// Ensure the three actions have distinct values (iota correctness)
|
|
assert.NotEqual(t, redeemActionCreate, redeemActionRedeem)
|
|
assert.NotEqual(t, redeemActionCreate, redeemActionSkipCompleted)
|
|
assert.NotEqual(t, redeemActionRedeem, redeemActionSkipCompleted)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RedeemCode.IsUsed / CanUse interaction with resolveRedeemAction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolveRedeemAction_IsUsedCanUseConsistency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
usedCode := &RedeemCode{Status: StatusUsed}
|
|
unusedCode := &RedeemCode{Status: StatusUnused}
|
|
|
|
// Verify our decision function is consistent with the domain model methods
|
|
assert.True(t, usedCode.IsUsed())
|
|
assert.False(t, usedCode.CanUse())
|
|
assert.Equal(t, redeemActionSkipCompleted, resolveRedeemAction(usedCode, nil))
|
|
|
|
assert.False(t, unusedCode.IsUsed())
|
|
assert.True(t, unusedCode.CanUse())
|
|
assert.Equal(t, redeemActionRedeem, resolveRedeemAction(unusedCode, nil))
|
|
}
|
|
|
|
func TestExpectedNotificationProviderKeyPrefersOrderInstanceProvider(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := payment.NewRegistry()
|
|
registry.Register(paymentFulfillmentTestProvider{
|
|
key: payment.TypeAlipay,
|
|
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
|
|
})
|
|
|
|
assert.Equal(t,
|
|
payment.TypeEasyPay,
|
|
expectedNotificationProviderKey(registry, payment.TypeAlipay, "", payment.TypeEasyPay),
|
|
)
|
|
}
|
|
|
|
func TestExpectedNotificationProviderKeyUsesRegistryMappingForLegacyOrders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := payment.NewRegistry()
|
|
registry.Register(paymentFulfillmentTestProvider{
|
|
key: payment.TypeEasyPay,
|
|
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
|
|
})
|
|
|
|
assert.Equal(t,
|
|
payment.TypeEasyPay,
|
|
expectedNotificationProviderKey(registry, payment.TypeAlipay, "", ""),
|
|
)
|
|
}
|
|
|
|
func TestExpectedNotificationProviderKeyFallsBackToPaymentType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t,
|
|
payment.TypeWxpay,
|
|
expectedNotificationProviderKey(nil, payment.TypeWxpay, "", ""),
|
|
)
|
|
}
|
|
|
|
func TestExpectedNotificationProviderKeyPrefersOrderSnapshotProviderKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := payment.NewRegistry()
|
|
registry.Register(paymentFulfillmentTestProvider{
|
|
key: payment.TypeAlipay,
|
|
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
|
|
})
|
|
|
|
assert.Equal(t,
|
|
payment.TypeEasyPay,
|
|
expectedNotificationProviderKey(registry, payment.TypeAlipay, payment.TypeEasyPay, ""),
|
|
)
|
|
}
|
|
|
|
func TestExpectedNotificationProviderKeyForOrderUsesSnapshotProviderKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := payment.NewRegistry()
|
|
registry.Register(paymentFulfillmentTestProvider{
|
|
key: payment.TypeAlipay,
|
|
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
|
|
})
|
|
|
|
order := &dbent.PaymentOrder{
|
|
PaymentType: payment.TypeAlipay,
|
|
ProviderSnapshot: map[string]any{
|
|
"schema_version": 1,
|
|
"provider_key": payment.TypeEasyPay,
|
|
},
|
|
}
|
|
|
|
assert.Equal(t,
|
|
payment.TypeEasyPay,
|
|
expectedNotificationProviderKeyForOrder(registry, order, ""),
|
|
)
|
|
}
|
|
|
|
func TestValidateProviderNotificationMetadataRejectsWxpaySnapshotMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
order := &dbent.PaymentOrder{
|
|
PaymentType: payment.TypeWxpay,
|
|
ProviderSnapshot: map[string]any{
|
|
"schema_version": 1,
|
|
"merchant_app_id": "wx-app-expected",
|
|
"merchant_id": "mch-expected",
|
|
"currency": "CNY",
|
|
},
|
|
}
|
|
|
|
err := validateProviderNotificationMetadata(order, payment.TypeWxpay, map[string]string{
|
|
"appid": "wx-app-other",
|
|
"mchid": "mch-expected",
|
|
"currency": "CNY",
|
|
"trade_state": "SUCCESS",
|
|
})
|
|
assert.ErrorContains(t, err, "wxpay appid mismatch")
|
|
}
|
|
|
|
func TestValidateProviderNotificationMetadataAllowsLegacyOrdersWithoutSnapshotFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
order := &dbent.PaymentOrder{
|
|
PaymentType: payment.TypeWxpay,
|
|
ProviderSnapshot: map[string]any{
|
|
"schema_version": 1,
|
|
"provider_instance_id": "9",
|
|
"provider_key": payment.TypeWxpay,
|
|
},
|
|
}
|
|
|
|
err := validateProviderNotificationMetadata(order, payment.TypeWxpay, map[string]string{
|
|
"appid": "wx-app-runtime",
|
|
"mchid": "mch-runtime",
|
|
"currency": "CNY",
|
|
"trade_state": "SUCCESS",
|
|
})
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestParseLegacyPaymentOrderID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oid, ok := parseLegacyPaymentOrderID("sub2_42", &dbent.NotFoundError{})
|
|
assert.True(t, ok)
|
|
assert.EqualValues(t, 42, oid)
|
|
|
|
_, ok = parseLegacyPaymentOrderID("42", &dbent.NotFoundError{})
|
|
assert.False(t, ok)
|
|
|
|
_, ok = parseLegacyPaymentOrderID("sub2_42", errors.New("db down"))
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestIsValidProviderAmount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.True(t, isValidProviderAmount(0.01))
|
|
assert.False(t, isValidProviderAmount(0))
|
|
assert.False(t, isValidProviderAmount(-1))
|
|
assert.False(t, isValidProviderAmount(math.NaN()))
|
|
assert.False(t, isValidProviderAmount(math.Inf(1)))
|
|
}
|
|
|
|
func TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
order := &dbent.PaymentOrder{
|
|
PaymentType: payment.TypeAlipay,
|
|
ProviderSnapshot: map[string]any{
|
|
"schema_version": 2,
|
|
"merchant_app_id": "alipay-app-expected",
|
|
},
|
|
}
|
|
|
|
err := validateProviderNotificationMetadata(order, payment.TypeAlipay, map[string]string{
|
|
"app_id": "alipay-app-other",
|
|
})
|
|
assert.ErrorContains(t, err, "alipay app_id mismatch")
|
|
}
|
|
|
|
func TestValidateProviderNotificationMetadataRejectsEasyPaySnapshotMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
order := &dbent.PaymentOrder{
|
|
PaymentType: payment.TypeAlipay,
|
|
ProviderSnapshot: map[string]any{
|
|
"schema_version": 2,
|
|
"merchant_id": "pid-expected",
|
|
},
|
|
}
|
|
|
|
err := validateProviderNotificationMetadata(order, payment.TypeEasyPay, map[string]string{
|
|
"pid": "pid-other",
|
|
})
|
|
assert.ErrorContains(t, err, "easypay pid mismatch")
|
|
}
|