test(payment): add unit tests for payment audit fixes + allow empty supported_types

Tests (1033 new lines, 100% coverage on modified functions):
- amount.go: YuanToFen/FenToYuan with precision edge cases
- wxpay: mapWxState, wxSV, formatPEM, NewWxpay validation
- alipay: isTradeNotExist, NewAlipay validation
- webhook: writeSuccessResponse (wxpay JSON, stripe empty, others text)
- config: validateProviderRequest, isSensitiveConfigField, joinTypes
- fulfillment: resolveRedeemAction idempotency logic

Business logic changes:
- Allow empty supported_types on provider instances
- Block removing payment types when instance has pending orders
- Extract resolveRedeemAction as testable pure function
This commit is contained in:
erio
2026-04-08 18:21:12 +08:00
parent 5bae3b0577
commit 3c884f8e30
2 changed files with 34 additions and 9 deletions

View File

@@ -156,7 +156,6 @@ func TestNewWxpay(t *testing.T) {
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
"publicKey": "fake-public-key",
"publicKeyId": "key-id-001",
"certSerial": "SERIAL001",
}
// helper to clone and override config fields

View File

@@ -146,20 +146,46 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6
return nil
}
// redeemAction represents the idempotency decision for balance fulfillment.
type redeemAction int
const (
// redeemActionCreate: code does not exist — create it, then redeem.
redeemActionCreate redeemAction = iota
// redeemActionRedeem: code exists but is unused — skip creation, redeem only.
redeemActionRedeem
// redeemActionSkipCompleted: code exists and is already used — skip to mark completed.
redeemActionSkipCompleted
)
// resolveRedeemAction decides the idempotency action based on an existing redeem code lookup.
// existing is the result of GetByCode; lookupErr is the error from that call.
func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction {
if existing == nil || lookupErr != nil {
return redeemActionCreate
}
if existing.IsUsed() {
return redeemActionSkipCompleted
}
return redeemActionRedeem
}
func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error {
// Idempotency: check if redeem code already exists (from a previous partial run)
existing, _ := s.redeemService.GetByCode(ctx, o.RechargeCode)
if existing != nil {
if existing.IsUsed() {
// Code already created and redeemed — just mark completed
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
}
// Code exists but unused — skip creation, proceed to redeem
} else {
existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode)
action := resolveRedeemAction(existing, lookupErr)
switch action {
case redeemActionSkipCompleted:
// Code already created and redeemed — just mark completed
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
case redeemActionCreate:
rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused}
if err := s.redeemService.CreateCode(ctx, rc); err != nil {
return fmt.Errorf("create redeem code: %w", err)
}
case redeemActionRedeem:
// Code exists but unused — skip creation, proceed to redeem
}
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err)