555 lines
17 KiB
Go
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))
|
|
}
|
|
}
|