完善本地质量门禁与资金链路测试
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -694,6 +696,75 @@ func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentPaidNotificationsAreIdempotent(t *testing.T) {
|
||||
db := database.NewTestDBWithName("payment-concurrent")
|
||||
if err := database.MigrateRentalTransactionTestSchema(db); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sqlite db: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(8)
|
||||
orderRepo := ordermodule.NewRepository(db)
|
||||
repo := NewRepository(db, nil, orderRepo)
|
||||
fixture := createPayableOrderFixture(t, db, "CONCURRENT", true)
|
||||
params := map[string]string{
|
||||
"third_order_id": fixture.Payment.ThirdOrderID,
|
||||
"status": "paid",
|
||||
"amount": "1000",
|
||||
"provider_order_id": "PROVIDER-CONCURRENT",
|
||||
}
|
||||
|
||||
const callers = 8
|
||||
results := make(chan error, callers)
|
||||
var group sync.WaitGroup
|
||||
for i := 0; i < callers; i++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
var callErr error
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
_, callErr = repo.HandleNotify(t.Context(), "mock", params, "status=paid", "application/x-www-form-urlencoded", "")
|
||||
if callErr == nil || !strings.Contains(callErr.Error(), "locked") {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
results <- callErr
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
for callErr := range results {
|
||||
if callErr != nil {
|
||||
t.Fatalf("concurrent HandleNotify() error = %v", callErr)
|
||||
}
|
||||
}
|
||||
|
||||
var latestPayment model.PaymentOrder
|
||||
if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil {
|
||||
t.Fatalf("find payment: %v", err)
|
||||
}
|
||||
if latestPayment.Status != "paid" {
|
||||
t.Fatalf("payment status = %q, want paid", latestPayment.Status)
|
||||
}
|
||||
var latestOrder model.RentalOrder
|
||||
if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil {
|
||||
t.Fatalf("find order: %v", err)
|
||||
}
|
||||
if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" {
|
||||
t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus)
|
||||
}
|
||||
var paidCount int64
|
||||
if err := db.Model(&model.PaymentOrder{}).Where("id = ? AND status = 'paid'", fixture.Payment.ID).Count(&paidCount).Error; err != nil {
|
||||
t.Fatalf("count paid payment: %v", err)
|
||||
}
|
||||
if paidCount != 1 {
|
||||
t.Fatalf("paid payment rows = %d, want 1", paidCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryClosedPaymentReturnsLocalStatus 验证关闭状态不再依赖渠道配置或继续轮询渠道。
|
||||
func TestQueryClosedPaymentReturnsLocalStatus(t *testing.T) {
|
||||
db := setupPaymentTestDB(t)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package withdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
"hfb_sys/backend/pkg/crypto"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type withdrawalFixture struct {
|
||||
db *gorm.DB
|
||||
repo *Repository
|
||||
userID uint64
|
||||
adminID uint64
|
||||
accountID uint64
|
||||
}
|
||||
|
||||
func newWithdrawalFixture(t *testing.T) withdrawalFixture {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:withdrawal-%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AdminUser{},
|
||||
&model.UserPaymentAccount{},
|
||||
&model.WithdrawalRequest{},
|
||||
&model.WalletAccount{},
|
||||
&model.WalletLedger{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate fixture: %v", err)
|
||||
}
|
||||
|
||||
user := model.User{Phone: "13800000001", Nickname: "test-user", Status: "active"}
|
||||
admin := model.AdminUser{Username: "reviewer", PasswordHash: "test", Nickname: "reviewer", Status: "active"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("create admin: %v", err)
|
||||
}
|
||||
account := model.UserPaymentAccount{
|
||||
UserID: user.ID, AccountType: "bank", AccountName: "Test User",
|
||||
AccountNo: "6222021234567890", BankName: "Test Bank", Status: "active",
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("create payment account: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.WalletAccount{
|
||||
UserID: user.ID, AvailableBalanceCent: 100_000, Status: "active",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create wallet: %v", err)
|
||||
}
|
||||
|
||||
return withdrawalFixture{
|
||||
db: db, repo: NewRepository(db, wallet.NewRepository(db), &crypto.MockEncryptor{}),
|
||||
userID: user.ID, adminID: admin.ID, accountID: account.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f withdrawalFixture) create(t *testing.T, amountCent int64) *WithdrawalDTO {
|
||||
t.Helper()
|
||||
dto, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{
|
||||
PaymentAccountID: f.accountID,
|
||||
AmountCent: amountCent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func (f withdrawalFixture) assertBalance(t *testing.T, available, frozen int64) {
|
||||
t.Helper()
|
||||
var account model.WalletAccount
|
||||
if err := f.db.Where("user_id = ?", f.userID).First(&account).Error; err != nil {
|
||||
t.Fatalf("load wallet: %v", err)
|
||||
}
|
||||
if account.AvailableBalanceCent != available || account.FrozenBalanceCent != frozen {
|
||||
t.Fatalf("balance = available:%d frozen:%d, want available:%d frozen:%d",
|
||||
account.AvailableBalanceCent, account.FrozenBalanceCent, available, frozen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalCreateAndCancelPreservesBalance(t *testing.T) {
|
||||
f := newWithdrawalFixture(t)
|
||||
dto := f.create(t, 20_000)
|
||||
if dto.Status != "pending" || dto.FeeCent != 0 || dto.ActualAmountCent != 20_000 {
|
||||
t.Fatalf("created withdrawal = %+v", dto)
|
||||
}
|
||||
f.assertBalance(t, 80_000, 20_000)
|
||||
|
||||
if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); err != nil {
|
||||
t.Fatalf("Cancel: %v", err)
|
||||
}
|
||||
f.assertBalance(t, 100_000, 0)
|
||||
if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); !errors.Is(err, ErrWithdrawalLocked) {
|
||||
t.Fatalf("second Cancel error = %v, want ErrWithdrawalLocked", err)
|
||||
}
|
||||
f.assertBalance(t, 100_000, 0)
|
||||
}
|
||||
|
||||
func TestWithdrawalRejectedReturnsFrozenBalanceOnce(t *testing.T) {
|
||||
f := newWithdrawalFixture(t)
|
||||
dto := f.create(t, 30_000)
|
||||
approved := false
|
||||
result, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{
|
||||
Approved: &approved,
|
||||
Remark: "account mismatch",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Review rejected: %v", err)
|
||||
}
|
||||
if result.Status != "rejected" || result.ReviewedByName != "reviewer" {
|
||||
t.Fatalf("review result = %+v", result)
|
||||
}
|
||||
f.assertBalance(t, 100_000, 0)
|
||||
if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); !errors.Is(err, ErrWithdrawalLocked) {
|
||||
t.Fatalf("second Review error = %v, want ErrWithdrawalLocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalApprovedAndPaidConsumesFrozenBalanceOnce(t *testing.T) {
|
||||
f := newWithdrawalFixture(t)
|
||||
dto := f.create(t, 40_000)
|
||||
approved := true
|
||||
if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); err != nil {
|
||||
t.Fatalf("Review approved: %v", err)
|
||||
}
|
||||
f.assertBalance(t, 60_000, 40_000)
|
||||
|
||||
result, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{
|
||||
PaymentProofURL: "proof/test.png",
|
||||
Remark: "paid",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmPayment: %v", err)
|
||||
}
|
||||
if result.Status != "completed" || result.PaidByName != "reviewer" {
|
||||
t.Fatalf("payment result = %+v", result)
|
||||
}
|
||||
f.assertBalance(t, 60_000, 0)
|
||||
if _, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{}); !errors.Is(err, ErrWithdrawalLocked) {
|
||||
t.Fatalf("second ConfirmPayment error = %v, want ErrWithdrawalLocked", err)
|
||||
}
|
||||
f.assertBalance(t, 60_000, 0)
|
||||
}
|
||||
|
||||
func TestWithdrawalCreateRollsBackWhenBalanceIsInsufficient(t *testing.T) {
|
||||
f := newWithdrawalFixture(t)
|
||||
_, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{
|
||||
PaymentAccountID: f.accountID,
|
||||
AmountCent: 100_001,
|
||||
})
|
||||
if !errors.Is(err, wallet.ErrInsufficientBalance) {
|
||||
t.Fatalf("Create error = %v, want ErrInsufficientBalance", err)
|
||||
}
|
||||
var count int64
|
||||
if err := f.db.Model(&model.WithdrawalRequest{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count withdrawals: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("withdrawal count = %d, want 0", count)
|
||||
}
|
||||
f.assertBalance(t, 100_000, 0)
|
||||
}
|
||||
Reference in New Issue
Block a user