Files
team-api/tests/integration/admin/wallet_test.go
T

555 lines
17 KiB
Go

//go:build integration
package admin_test
import (
"fmt"
"sync"
"testing"
"github.com/qianfree/team-api/tests/integration/admin/testinfra"
)
func TestWalletList(t *testing.T) {
client := testinfra.GetAuthedClient(t)
resp := client.Get("/api/admin/wallets", map[string]string{
"page": "1",
"page_size": "10",
})
testinfra.AssertPaginatedList(t, resp, 0)
t.Logf("Wallet list retrieved successfully")
}
func TestWalletInfo(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
resp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
resp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
resp.DecodeData(t, &data)
if data.Balance < 0 {
t.Fatalf("expected balance >= 0, got %f", data.Balance)
}
if data.FrozenBalance < 0 {
t.Fatalf("expected frozen_balance >= 0, got %f", data.FrozenBalance)
}
t.Logf("Wallet info for tenant %d: balance=%f, frozen=%f",
tenantID, data.Balance, data.FrozenBalance)
}
func TestWalletAdjust(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// Add balance
resp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": 10.5,
"description": "integration test adjustment",
})
resp.AssertSuccess(t)
// Verify balance increased to exact value
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
}
infoResp.DecodeData(t, &data)
testinfra.AssertFloatEqual(t, 10.5, data.Balance, 0.0001, "balance after +10.5 adjustment")
t.Logf("Wallet adjust succeeded, new balance: %f", data.Balance)
}
func TestWalletTransactions(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// First adjust wallet to create a transaction
adjustResp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": 5.0,
"description": "test transaction for listing",
})
adjustResp.AssertSuccess(t)
// Then list transactions
resp := client.Get(fmt.Sprintf("/api/admin/wallets/%d/transactions", tenantID), map[string]string{
"page": "1",
"page_size": "20",
})
testinfra.AssertPaginatedList(t, resp, 1)
total := resp.GetTotal(t)
t.Logf("Wallet transactions for tenant %d: total=%d", tenantID, total)
}
func TestWalletWarningThreshold(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// Set warning threshold
threshold := 1.5
resp := client.Put(fmt.Sprintf("/api/admin/wallets/%d/warning-threshold", tenantID), map[string]any{
"threshold": threshold,
})
resp.AssertSuccess(t)
// Verify threshold was set
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
WarningThreshold *float64 `json:"warning_threshold"`
}
infoResp.DecodeData(t, &data)
if data.WarningThreshold == nil {
t.Fatal("expected warning_threshold to be set, got nil")
}
testinfra.AssertFloatEqual(t, 1.5, *data.WarningThreshold, 0.0001, "warning_threshold")
t.Logf("Warning threshold set to %f for tenant %d", *data.WarningThreshold, tenantID)
}
// --- P2: Wallet business logic tests ---
// TestWalletAdjustPrecision verifies that sequential adjustments accumulate precisely.
// Business rule: wallet balance must reflect exact sum of all adjustments.
func TestWalletAdjustPrecision(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// Three sequential adjustments — balance should be exact sum
adjustments := []float64{10.5, 3.25, 1.25}
var expectedTotal float64
for _, amount := range adjustments {
expectedTotal += amount
resp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": amount,
"description": fmt.Sprintf("precision test adjustment %.2f", amount),
})
resp.AssertSuccess(t)
}
// Verify final balance is exactly the sum of adjustments
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
}
infoResp.DecodeData(t, &data)
testinfra.AssertFloatEqual(t, expectedTotal, data.Balance, 0.0001,
"balance after sequential adjustments")
// Verify transaction count matches adjustment count
txResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d/transactions", tenantID), map[string]string{
"page": "1",
"page_size": "20",
})
txTotal := txResp.GetTotal(t)
if txTotal < len(adjustments) {
t.Fatalf("expected at least %d transactions, got %d", len(adjustments), txTotal)
}
t.Logf("Precision verified: %v = %.4f (actual=%.4f)", adjustments, expectedTotal, data.Balance)
}
// TestWalletNegativeAdjust verifies deduction from wallet (negative adjustment).
// Business rule: wallet supports both positive (recharge) and negative (deduct) adjustments.
func TestWalletNegativeAdjust(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// First add 20.0
client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": 20.0,
"description": "initial deposit for deduction test",
}).AssertSuccess(t)
// Then deduct 7.5
client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": -7.5,
"description": "deduction test",
}).AssertSuccess(t)
// Verify balance is exactly 12.5
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
}
infoResp.DecodeData(t, &data)
testinfra.AssertFloatEqual(t, 12.5, data.Balance, 0.0001,
"balance after +20.0 and -7.5")
t.Logf("Negative adjust verified: 20.0 - 7.5 = %.4f", data.Balance)
}
// TestWalletOverdraftPrevention verifies that deducting more than balance is handled correctly.
// Business rule: wallet balance should not go negative from admin adjustments,
// or if allowed, the balance must be accurately tracked.
func TestWalletOverdraftPrevention(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// Add small amount first
client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": 5.0,
"description": "small deposit for overdraft test",
}).AssertSuccess(t)
// Try to deduct more than balance (deduct 100.0 when only 5.0 available)
overdraftResp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": -100.0,
"description": "overdraft attempt",
})
// Either the overdraft is rejected (error), or if admin can force it, balance goes negative
// Both are valid business behaviors — we verify consistency either way
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
}
infoResp.DecodeData(t, &data)
if overdraftResp.Code == 0 {
// Admin overdraft was allowed — balance should be 5.0 - 100.0 = -95.0
testinfra.AssertFloatEqual(t, -95.0, data.Balance, 0.0001,
"balance after allowed overdraft")
t.Logf("Overdraft allowed: balance = %.4f (admin override)", data.Balance)
} else {
// Overdraft was rejected — balance should still be 5.0
testinfra.AssertFloatEqual(t, 5.0, data.Balance, 0.0001,
"balance after rejected overdraft")
t.Logf("Overdraft prevented: balance = %.4f (still 5.0)", data.Balance)
}
}
// TestWalletConcurrentAdjust verifies wallet balance consistency under concurrent modifications.
// Business rule: concurrent balance adjustments must not cause lost updates or data corruption.
// The final balance must equal the exact sum of all successful adjustments.
func TestWalletConcurrentAdjust(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
const numGoroutines = 10
const amountPerOp = 1.0
// Use a shared HTTP client — http.Client is goroutine-safe
var wg sync.WaitGroup
results := make(chan *testinfra.APIResponse, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": amountPerOp,
"description": "concurrent adjustment",
})
results <- resp
}()
}
wg.Wait()
close(results)
// Count successes
var successCount int
for resp := range results {
if resp.Code == 0 {
successCount++
}
}
if successCount == 0 {
t.Fatal("all concurrent adjustments failed — possible server issue")
}
// Verify final balance equals successCount * amountPerOp
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
}
infoResp.DecodeData(t, &data)
expectedBalance := float64(successCount) * amountPerOp
testinfra.AssertFloatEqual(t, expectedBalance, data.Balance, 0.0001,
fmt.Sprintf("balance after %d concurrent +%.1f adjustments", successCount, amountPerOp))
t.Logf("Concurrent adjust: %d/%d succeeded, balance=%.4f (expected=%.4f)",
successCount, numGoroutines, data.Balance, expectedBalance)
}
// TestWalletConcurrentMixedAdjust verifies wallet balance consistency
// when concurrent operations include both additions and deductions.
// Business rule: mixed concurrent operations must maintain balance integrity.
func TestWalletConcurrentMixedAdjust(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// Pre-load balance to support deductions
const preload = 50.0
client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": preload,
"description": "preload for mixed concurrent test",
}).AssertSuccess(t)
type opResult struct {
amount float64
resp *testinfra.APIResponse
}
const numOps = 10
results := make(chan opResult, numOps)
var wg sync.WaitGroup
for i := 0; i < numOps; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
// Even indices add, odd indices deduct
amount := 2.0
if idx%2 == 1 {
amount = -1.5
}
resp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": amount,
"description": fmt.Sprintf("mixed concurrent op %d", idx),
})
results <- opResult{amount: amount, resp: resp}
}(i)
}
wg.Wait()
close(results)
var actualDelta float64
var successCount int
for r := range results {
if r.resp.Code == 0 {
actualDelta += r.amount
successCount++
}
}
// Verify final balance = preload + actualDelta
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
}
infoResp.DecodeData(t, &data)
expectedBalance := preload + actualDelta
testinfra.AssertFloatEqual(t, expectedBalance, data.Balance, 0.0001,
fmt.Sprintf("balance after %d mixed concurrent ops (delta=%.4f)", successCount, actualDelta))
t.Logf("Mixed concurrent: preload=%.1f + delta=%.4f = %.4f (actual=%.4f)",
preload, actualDelta, expectedBalance, data.Balance)
}
// TestWalletAvailableBalance verifies the relationship between balance, frozen_balance, and available balance.
// Business rule: available_balance = balance - frozen_balance, must never be negative.
func TestWalletAvailableBalance(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// Add balance
client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": 100.0,
"description": "deposit for available balance test",
}).AssertSuccess(t)
infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil)
infoResp.AssertSuccess(t)
var data struct {
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
infoResp.DecodeData(t, &data)
available := data.Balance - data.FrozenBalance
if available < 0 {
t.Fatalf("available balance (%.4f) is negative: balance=%.4f, frozen=%.4f",
available, data.Balance, data.FrozenBalance)
}
if data.FrozenBalance > data.Balance {
t.Fatalf("frozen_balance (%.4f) exceeds total balance (%.4f)",
data.FrozenBalance, data.Balance)
}
t.Logf("Available balance verified: balance=%.4f - frozen=%.4f = available=%.4f",
data.Balance, data.FrozenBalance, available)
}
// TestWalletAdjustNonExistentTenant verifies that adjusting a non-existent tenant's wallet fails.
// Business rule: wallet operations must validate tenant existence.
func TestWalletAdjustNonExistentTenant(t *testing.T) {
client := testinfra.GetAuthedClient(t)
resp := client.Post("/api/admin/wallets/999999999/adjust", map[string]any{
"amount": 10.0,
"description": "adjustment for non-existent tenant",
})
if resp.Code == 0 {
t.Fatal("expected error when adjusting wallet for non-existent tenant, got success")
}
// Also verify wallet info for non-existent tenant
infoResp := client.Get("/api/admin/wallets/999999999", nil)
if infoResp.Code == 0 {
t.Fatal("expected error when getting wallet for non-existent tenant, got success")
}
}
// TestWalletTransactionFieldValidation verifies transaction records contain correct fields.
// Business rule: every balance change must produce a transaction with accurate amount, type, and balance_after.
func TestWalletTransactionFieldValidation(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
// Create a known adjustment
client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": 25.75,
"description": "transaction field validation test",
}).AssertSuccess(t)
// Get transactions
txResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d/transactions", tenantID), map[string]string{
"page": "1",
"page_size": "10",
})
txResp.AssertSuccess(t)
var txData struct {
List []struct {
Id int64 `json:"id"`
Amount float64 `json:"amount"`
BalanceAfter float64 `json:"balance_after"`
Type string `json:"type"`
Description string `json:"description"`
} `json:"list"`
}
txResp.DecodeData(t, &txData)
if len(txData.List) == 0 {
t.Fatal("expected at least 1 transaction after adjustment")
}
tx := txData.List[0]
testinfra.AssertFloatEqual(t, 25.75, tx.Amount, 0.0001, "transaction amount")
testinfra.AssertFloatEqual(t, 25.75, tx.BalanceAfter, 0.0001, "balance_after")
if tx.Type == "" {
t.Fatal("transaction type should not be empty")
}
if tx.Description != "transaction field validation test" {
t.Fatalf("expected description='transaction field validation test', got %q", tx.Description)
}
t.Logf("Transaction fields validated: amount=%.4f, balance_after=%.4f, type=%q",
tx.Amount, tx.BalanceAfter, tx.Type)
}
// ─── 边界值测试 ────────────────────────────────────────────────────
// TestWalletAdjust_ZeroAmount 验证零金额调整被拒绝
// Business rule: 零金额调整没有实际意义,应被拒绝
func TestWalletAdjust_ZeroAmount(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
resp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{
"amount": 0,
"description": "zero amount test",
})
if resp.Code == 0 {
t.Log("server accepted zero amount adjustment — may need validation")
} else {
t.Logf("zero amount rejected: code=%d msg=%q", resp.Code, resp.Message)
}
}
// TestWalletAdjust_NegativeWarningThreshold 验证负数预警阈值被拒绝
// Business rule: 预警阈值不能为负数
func TestWalletAdjust_NegativeWarningThreshold(t *testing.T) {
client := testinfra.GetAuthedClient(t)
tenantID, cleanup := testinfra.CreateTestTenant(t, client)
defer cleanup()
resp := client.Put(fmt.Sprintf("/api/admin/wallets/%d/warning-threshold", tenantID), map[string]any{
"warning_threshold": -5.0,
})
if resp.Code == 0 {
t.Log("server accepted negative warning threshold — may need validation")
} else {
t.Logf("negative warning threshold rejected: code=%d msg=%q", resp.Code, resp.Message)
}
}
// TestWalletList_PaginationBoundary 验证钱包列表分页边界
func TestWalletList_PaginationBoundary(t *testing.T) {
client := testinfra.GetAuthedClient(t)
// page=999999
resp := client.Get("/api/admin/wallets", map[string]string{
"page": "999999",
"page_size": "10",
})
resp.AssertSuccess(t)
var data struct {
List []any `json:"list"`
}
resp.DecodeData(t, &data)
if len(data.List) != 0 {
t.Fatalf("page=999999 should return empty list, got %d items", len(data.List))
}
}