完善本地质量门禁与资金链路测试

This commit is contained in:
yml2213
2026-09-02 13:50:19 +08:00
parent dd2b45bd2c
commit 208f12ddb4
80 changed files with 12245 additions and 11541 deletions
@@ -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)
}