//go:build unit package service import ( "context" "errors" "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) }