Files
NianAIGC/backend/internal/payments/refunds_test.go
T
2026-10-12 13:57:23 +08:00

194 lines
7.2 KiB
Go

package payments
import (
"context"
"errors"
"net/http"
"testing"
"time"
)
type refundTestRepo struct {
*testRepo
refunds map[string]Refund
byRefundKey map[string]string
records int
}
func (r *refundTestRepo) ListAdminPaymentOrders(_ context.Context, _ string, _, _ int) ([]AdminOrder, error) {
return nil, nil
}
func (r *refundTestRepo) CreatePaymentRefund(_ context.Context, refund Refund) (Refund, error) {
if id := r.byRefundKey[refund.RequestKey]; id != "" {
return r.refunds[id], nil
}
r.refunds[refund.ID] = refund
r.byRefundKey[refund.RequestKey] = refund.ID
return refund, nil
}
func (r *refundTestRepo) GetPaymentRefund(_ context.Context, id string) (Refund, error) {
refund, ok := r.refunds[id]
if !ok {
return Refund{}, ErrNotFound
}
return refund, nil
}
func (r *refundTestRepo) ListPaymentRefunds(_ context.Context, orderID string) ([]Refund, error) {
var out []Refund
for _, refund := range r.refunds {
if refund.OrderID == orderID {
out = append(out, refund)
}
}
return out, nil
}
func (r *refundTestRepo) PendingPaymentRefunds(_ context.Context, _ int) ([]Refund, error) {
var out []Refund
for _, refund := range r.refunds {
if refund.Status != RefundSuccess && refund.Status != RefundClosed && refund.Status != RefundRejected {
out = append(out, refund)
}
}
return out, nil
}
func (r *refundTestRepo) TouchPaymentRefund(_ context.Context, id string) (Refund, error) {
refund := r.refunds[id]
refund.UpdatedAt = time.Now().UTC()
r.refunds[id] = refund
return refund, nil
}
func (r *refundTestRepo) RejectPaymentRefund(_ context.Context, id, reason string) (Refund, error) {
refund := r.refunds[id]
refund.Status, refund.FailureReason = RefundRejected, reason
r.refunds[id] = refund
return refund, nil
}
func (r *refundTestRepo) RecordPaymentRefund(_ context.Context, txn RefundTransaction) (Refund, error) {
refund := r.refunds[txn.ID]
if txn.Status == RefundSuccess && refund.Status != RefundSuccess {
r.records++
}
refund.Status = txn.Status
refund.ProviderID = txn.ProviderID
r.refunds[txn.ID] = refund
return refund, nil
}
type refundTestProvider struct {
*testProvider
txn RefundTransaction
createErr, queryErr error
createIDs []string
queries int
}
func (p *refundTestProvider) CreateRefund(_ context.Context, refund Refund) (RefundTransaction, error) {
p.createIDs = append(p.createIDs, refund.ID)
if p.createErr != nil {
return RefundTransaction{}, p.createErr
}
txn := p.txn
txn.ID, txn.OrderID, txn.MchID, txn.TransactionID = refund.ID, refund.OrderID, refund.MchID, refund.TransactionID
txn.AmountFen, txn.TotalFen, txn.Currency = refund.AmountFen, refund.TotalFen, refund.Currency
return txn, nil
}
func (p *refundTestProvider) QueryRefund(_ context.Context, _ string) (RefundTransaction, error) {
p.queries++
return p.txn, p.queryErr
}
func (p *refundTestProvider) ParseRefundNotification(_ context.Context, _ *http.Request) (RefundTransaction, error) {
return p.txn, nil
}
func newRefundService() (*Service, *refundTestRepo, *refundTestProvider, Order) {
r := &refundTestRepo{testRepo: &testRepo{orders: map[string]Order{}, byKey: map[string]string{}}, refunds: map[string]Refund{}, byRefundKey: map[string]string{}}
p := &refundTestProvider{testProvider: &testProvider{}, txn: RefundTransaction{ProviderID: "wx-refund", Status: RefundProcessing}}
s := NewService(r, p, Config{Enabled: true, MchID: "mch", AppID: "app"})
order := Order{ID: "order1", OrganizationID: "org1", MchID: "mch", AppID: "app", TransactionID: "wx-pay", AmountFen: 10000, Currency: "CNY", Status: StatusPaid}
r.orders[order.ID] = order
return s, r, p, order
}
func TestRefundAcceptanceDoesNotDebitUntilVerifiedSuccess(t *testing.T) {
s, repo, provider, order := newRefundService()
refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000)
if err != nil || refund.Status != RefundProcessing || repo.records != 0 {
t.Fatalf("acceptance incorrectly debited: %+v %v %d", refund, err, repo.records)
}
provider.txn = RefundTransaction{ID: refund.ID, OrderID: order.ID, MchID: "mch", TransactionID: order.TransactionID, ProviderID: "wx-refund", Status: RefundSuccess, AmountFen: 4999, TotalFen: order.AmountFen, Currency: "CNY", SuccessTime: time.Now()}
if err := s.RefundNotify(context.Background(), &http.Request{}); !errors.Is(err, ErrVerification) || repo.records != 0 {
t.Fatalf("mismatched callback: %v %d", err, repo.records)
}
provider.txn.AmountFen = 5000
for i := 0; i < 2; i++ {
if err := s.RefundNotify(context.Background(), &http.Request{}); err != nil {
t.Fatal(err)
}
}
if repo.records != 1 {
t.Fatalf("duplicate debit: %d", repo.records)
}
}
func TestRefundTimeoutReusesSameRemoteID(t *testing.T) {
s, repo, provider, order := newRefundService()
provider.createErr = errors.New("gateway timeout")
refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000)
if err != nil || refund.Status != RefundPending || repo.records != 0 {
t.Fatalf("timeout not pending: %+v %v", refund, err)
}
again, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000)
if err != nil || again.ID != refund.ID {
t.Fatalf("retry id changed: %+v %v", again, err)
}
provider.createErr = nil
provider.queryErr = ErrRemoteNotFound
current := repo.refunds[refund.ID]
current.UpdatedAt = time.Now().Add(-time.Minute)
repo.refunds[refund.ID] = current
if err := s.Reconcile(context.Background()); err != nil {
t.Fatal(err)
}
for _, id := range provider.createIDs {
if id != refund.ID {
t.Fatalf("new remote refund ID: %q", id)
}
}
if repo.refunds[refund.ID].Status != RefundProcessing || repo.records != 0 {
t.Fatalf("unexpected reconciled state: %+v", repo.refunds[refund.ID])
}
}
func TestRefundValidationAndMerchantRotation(t *testing.T) {
s, repo, provider, order := newRefundService()
for _, amount := range []int64{0, -1, 10001} {
if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", "原因", amount); err == nil {
t.Fatalf("accepted amount %d", amount)
}
}
if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", " ", 100); !errors.Is(err, ErrInvalidAmount) {
t.Fatal(err)
}
if len(provider.createIDs) != 0 {
t.Fatal("provider called before validation")
}
order.MchID = "old-merchant"
repo.orders[order.ID] = order
if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", "原因", 100); !errors.Is(err, ErrConflict) {
t.Fatal(err)
}
}
func TestDefinitiveRefundRejectionReleasesPendingAndDoesNotRetry(t *testing.T) {
s, repo, provider, order := newRefundService()
provider.createErr = &RefundRejectionError{Reason: "商户退款资金不足"}
refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000)
if err != nil || refund.Status != RefundRejected || refund.FailureReason != "商户退款资金不足" || repo.records != 0 {
t.Fatalf("definitive rejection: %+v %v", refund, err)
}
again, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000)
if err != nil || again.ID != refund.ID || len(provider.createIDs) != 1 {
t.Fatalf("rejected request retried remotely: %+v %v %v", again, err, provider.createIDs)
}
}