app-cloudep-wallet-service/pkg/repository/user_wallet_manager_test.go

1232 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repository
import (
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity"
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
"context"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"testing"
)
func buildSchema(t *testing.T, db *gorm.DB) {
// 先手動建表
createTable := `
CREATE TABLE IF NOT EXISTS wallet (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
brand VARCHAR(50) NOT NULL,
uid VARCHAR(64) NOT NULL,
asset VARCHAR(32) NOT NULL,
balance DECIMAL(30,18) NOT NULL DEFAULT 0,
type TINYINT NOT NULL,
create_at INTEGER NOT NULL DEFAULT 0,
update_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
assert.NoError(t, db.Exec(createTable).Error)
}
func TestUserWalletManager_InitWallets(t *testing.T) {
_, db, teardown, err := SetupTestWalletRepository()
assert.NoError(t, err)
defer teardown()
buildSchema(t, db)
type args struct {
uid string
asset string
brand string
}
tests := []struct {
name string
args args
wantCount int
wantErr bool
validateDB bool
}{
{
name: "正常初始化一次",
args: args{uid: "user1", asset: "BTC", brand: "brandA"},
wantCount: len(wallet.AllTypes),
},
{
name: "再次初始化同一 UID/asset/brand應因 UNIQUE KEY 失敗",
args: args{uid: "user1", asset: "BTC", brand: "brandA"},
wantErr: true,
validateDB: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := NewUserWalletManager(db, tt.args.uid, tt.args.asset)
ctx := context.Background()
got, err := service.InitWallets(ctx, tt.args.brand)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
// 回傳 slice 長度應等於 AllTypes
assert.Len(t, got, tt.wantCount)
if tt.validateDB {
// 再查一次 DB確認確實寫入
var dbRows []entity.Wallet
err := db.WithContext(ctx).
Where("uid = ? AND asset = ? AND brand = ?", tt.args.uid, tt.args.asset, tt.args.brand).
Find(&dbRows).Error
assert.NoError(t, err)
assert.Len(t, dbRows, tt.wantCount)
// 檢查每一筆都初始為零
for _, w := range dbRows {
assert.Equal(t, decimal.Zero, w.Balance)
}
}
})
}
}
//
//func TestUserWalletManager_FetchAllWallets(t *testing.T) {
// _, db, teardown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer teardown()
//
// // 建表
// createTable := `
// CREATE TABLE IF NOT EXISTS wallet (
// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
// brand VARCHAR(50) NOT NULL,
// uid VARCHAR(64) NOT NULL,
// asset VARCHAR(32) NOT NULL,
// balance DECIMAL(30,18) NOT NULL DEFAULT 0,
// type TINYINT NOT NULL,
// create_at INTEGER NOT NULL DEFAULT 0,
// update_at INTEGER NOT NULL DEFAULT 0,
// PRIMARY KEY (id),
// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
// assert.NoError(t, db.Exec(createTable).Error)
//
// type seedRow struct {
// Brand string
// UID string
// Asset string
// Balance decimal.Decimal
// Type wallet.Types
// }
//
// type args struct {
// uid string
// asset string
// brand string
// }
// tests := []struct {
// name string
// args args
// seed []seedRow
// wantCount int
// }{
// {
// name: "no data returns empty",
// args: args{"u1", "BTC", "b1"},
// seed: nil,
// wantCount: 0,
// },
// {
// name: "single user many types",
// args: args{"u1", "BTC", "b1"},
// seed: []seedRow{
// {"b1", "u1", "BTC", decimal.NewFromInt(10), wallet.AllTypes[0]},
// {"b1", "u1", "BTC", decimal.NewFromInt(20), wallet.AllTypes[1]},
// {"b1", "u1", "BTC", decimal.NewFromInt(30), wallet.AllTypes[2]},
// },
// wantCount: 3,
// },
// {
// name: "mixed users and assets",
// args: args{"u2", "ETH", "b2"},
// seed: []seedRow{
// {"b1", "u1", "BTC", decimal.NewFromInt(10), wallet.AllTypes[0]},
// {"b2", "u2", "ETH", decimal.NewFromInt(15), wallet.AllTypes[1]},
// {"b2", "u2", "ETH", decimal.NewFromInt(25), wallet.AllTypes[2]},
// },
// wantCount: 2,
// },
// }
//
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// ctx := context.Background()
// // 清空表
// assert.NoError(t, db.Exec(`DELETE FROM wallet`).Error)
//
// // 插入 seed
// for _, row := range tt.seed {
// err := db.WithContext(ctx).Exec(
// `INSERT INTO wallet (brand, uid, asset, balance, type, create_at, update_at)
// VALUES (?, ?, ?, ?, ?, ?, ?)`,
// row.Brand, row.UID, row.Asset, row.Balance, row.Type,
// time.Now().Unix(), time.Now().Unix(),
// ).Error
// assert.NoError(t, err)
// }
//
// // 建立 service
// svc := NewWalletService(db, tt.args.uid, tt.args.asset)
//
// // 呼叫 GetAllBalances
// got, err := svc.GetAllBalances(ctx)
// assert.NoError(t, err)
// assert.Len(t, got, tt.wantCount)
//
// // 檢查回傳資料與本地快取一致
// ws := svc.(*WalletService)
// for _, w := range got {
// // 回傳的每筆都應該存在於 localBalances
// cached, ok := ws.localBalances[w.Type]
// assert.True(t, ok)
// assert.Equal(t, w.Balance, cached.Balance)
// assert.Equal(t, w.Asset, cached.Asset)
// assert.Equal(t, w.Type, cached.Type)
// }
// })
// }
//}
//
//func TestUserWalletManager_GetBalancesForTypes(t *testing.T) {
// _, db, teardown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer teardown()
//
// // 建表
// createTable := `
// CREATE TABLE IF NOT EXISTS wallet (
// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
// brand VARCHAR(50) NOT NULL,
// uid VARCHAR(64) NOT NULL,
// asset VARCHAR(32) NOT NULL,
// balance DECIMAL(30,18) NOT NULL DEFAULT 0,
// type TINYINT NOT NULL,
// create_at INTEGER NOT NULL DEFAULT 0,
// update_at INTEGER NOT NULL DEFAULT 0,
// PRIMARY KEY (id),
// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
// assert.NoError(t, db.Exec(createTable).Error)
//
// type seedRow struct {
// Brand string
// UID string
// Asset string
// Balance decimal.Decimal
// Type wallet.Types
// }
//
// tests := []struct {
// name string
// uid string
// asset string
// brand string
// seed []seedRow
// kinds []wallet.Types
// wantCount int
// }{
// {
// name: "no matching types returns empty",
// uid: "user1", asset: "BTC", brand: "b1",
// seed: []seedRow{
// {"b1", "user1", "BTC", decimal.NewFromInt(5), wallet.AllTypes[0]},
// },
// kinds: []wallet.Types{wallet.AllTypes[1]},
// wantCount: 0,
// },
// {
// name: "single type match",
// uid: "user1", asset: "BTC", brand: "b1",
// seed: []seedRow{
// {"b1", "user1", "BTC", decimal.NewFromInt(5), wallet.AllTypes[0]},
// {"b1", "user1", "BTC", decimal.NewFromInt(7), wallet.AllTypes[1]},
// },
// kinds: []wallet.Types{wallet.AllTypes[1]},
// wantCount: 1,
// },
// {
// name: "multiple type matches",
// uid: "user2", asset: "ETH", brand: "b2",
// seed: []seedRow{
// {"b2", "user2", "ETH", decimal.NewFromInt(3), wallet.AllTypes[0]},
// {"b2", "user2", "ETH", decimal.NewFromInt(8), wallet.AllTypes[2]},
// {"b2", "user2", "ETH", decimal.NewFromInt(10), wallet.AllTypes[1]},
// },
// kinds: []wallet.Types{wallet.AllTypes[0], wallet.AllTypes[2]},
// wantCount: 2,
// },
// }
//
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// ctx := context.Background()
// // 清表
// assert.NoError(t, db.Exec(`DELETE FROM wallet`).Error)
//
// // 插入種子資料
// for _, r := range tt.seed {
// assert.NoError(t, db.Exec(
// `INSERT INTO wallet (brand, uid, asset, balance, type, create_at, update_at)
// VALUES (?, ?, ?, ?, ?, ?, ?)`,
// r.Brand, r.UID, r.Asset, r.Balance, r.Type,
// time.Now().Unix(), time.Now().Unix(),
// ).Error)
// }
//
// // 建立 service
// svc := NewWalletService(db, tt.uid, tt.asset)
//
// // 呼叫 GetBalancesForTypes
// got, err := svc.GetBalancesForTypes(ctx, tt.kinds)
// assert.NoError(t, err)
// assert.Len(t, got, tt.wantCount)
//
// // 檢查每筆結果皆正確緩存
// ws := svc.(*WalletService)
// for _, w := range got {
// cached, ok := ws.localBalances[w.Type]
// assert.True(t, ok)
// assert.Equal(t, w.Balance, cached.Balance)
// assert.Equal(t, w.Asset, cached.Asset)
// }
// })
// }
//}
//
//func TestForUpdateLockBehavior(t *testing.T) {
// // 建立測試環境
// _, db, teardown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer teardown()
//
// // 建表
// createTable := `
// CREATE TABLE IF NOT EXISTS wallet (
// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
// brand VARCHAR(50) NOT NULL,
// uid VARCHAR(64) NOT NULL,
// asset VARCHAR(32) NOT NULL,
// balance DECIMAL(30,18) NOT NULL DEFAULT 0,
// type TINYINT NOT NULL,
// create_at INTEGER NOT NULL DEFAULT 0,
// update_at INTEGER NOT NULL DEFAULT 0,
// PRIMARY KEY (id),
// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
// assert.NoError(t, db.Exec(createTable).Error)
//
// // 種子資料:一筆 wallet
// ctx := context.Background()
// initial := entity.Wallet{
// Brand: "b1",
// UID: "user1",
// Asset: "BTC",
// Balance: decimal.NewFromInt(100),
// Type: wallet.AllTypes[0],
// }
// err = db.WithContext(ctx).Create(&initial).Error
// assert.NoError(t, err)
//
// // 讀回自動產生的 ID
// var seeded entity.Wallet
// assert.NoError(t, db.Where("uid = ? AND asset = ?", initial.UID, initial.Asset).
// First(&seeded).Error)
//
// // 開啟第一個 transaction 並 SELECT … FOR UPDATE
// tx1 := db.Begin()
// var locked entity.Wallet
// err = tx1.Clauses(clause.Locking{Strength: "UPDATE"}).
// WithContext(ctx).
// Where("id = ?", seeded.ID).
// Take(&locked).Error
// assert.NoError(t, err)
//
// // 啟動 goroutine 嘗試在第二 transaction 更新該 row
// done := make(chan error, 1)
// go func() {
// tx2 := db.Begin()
// // 試圖更新,被 FOR UPDATE 鎖住前應該會 block
// err := tx2.WithContext(ctx).
// Model(&entity.Wallet{}).
// Where("id = ?", seeded.ID).
// Update("balance", seeded.Balance.Add(decimal.NewFromInt(50))).Error
// done <- err
// }()
//
// // 等 100ms 確認尚未完成
// time.Sleep(100 * time.Millisecond)
// select {
// case err2 := <-done:
// t.Fatalf("expected update to be blocked, but completed with err=%v", err2)
// default:
// // 正在等待鎖
// }
//
// // 釋放鎖
// assert.NoError(t, tx1.Commit().Error)
//
// // 現在第二 transaction 應該很快完成
// select {
// case err2 := <-done:
// assert.NoError(t, err2)
// case <-time.After(500 * time.Millisecond):
// t.Fatal("update did not complete after lock released")
// }
//}
//
//func TestWalletService_IncreaseBalance(t *testing.T) {
// uid := "user1"
// asset := "BTC"
//
// type testCase struct {
// name string
// kind wallet.Types
// initial *entity.Wallet // nil 表示不存在
// orderID string
// amount decimal.Decimal
// wantErr error
// wantBalance decimal.Decimal
// wantTxCount int
// }
// tests := []testCase{
// {
// name: "missing wallet type",
// kind: wallet.AllTypes[0],
// initial: nil,
// orderID: "ord1",
// amount: decimal.NewFromInt(10),
// wantErr: repository.ErrRecordNotFound,
// wantBalance: decimal.Zero,
// wantTxCount: 0,
// },
// {
// name: "increase from zero",
// kind: wallet.AllTypes[1],
// initial: &entity.Wallet{Balance: decimal.Zero},
// orderID: "ord2",
// amount: decimal.NewFromInt(15),
// wantErr: nil,
// wantBalance: decimal.NewFromInt(15),
// wantTxCount: 1,
// },
// {
// name: "successful increment on non-zero",
// kind: wallet.AllTypes[2],
// initial: &entity.Wallet{Balance: decimal.NewFromInt(5)},
// orderID: "ord3",
// amount: decimal.NewFromInt(7),
// wantErr: nil,
// wantBalance: decimal.NewFromInt(12),
// wantTxCount: 1,
// },
// {
// name: "insufficient leads to error",
// kind: wallet.AllTypes[2],
// initial: &entity.Wallet{Balance: decimal.NewFromInt(3)},
// orderID: "ord4",
// amount: decimal.NewFromInt(-5),
// wantErr: repository.ErrBalanceInsufficient,
// wantBalance: decimal.NewFromInt(3),
// wantTxCount: 0,
// },
// }
//
// for _, tc := range tests {
// t.Run(tc.name, func(t *testing.T) {
// // 準備 WalletService
// svc := NewWalletService(nil, uid, asset).(*WalletService)
// // 初始化本地快取
// if tc.initial != nil {
// svc.localBalances = map[wallet.Types]entity.Wallet{tc.kind: *tc.initial}
// } else {
// svc.localBalances = map[wallet.Types]entity.Wallet{}
// }
// // 清空交易紀錄
// svc.transactions = nil
//
// // 執行
// err := svc.IncreaseBalance(tc.kind, tc.orderID, tc.amount)
//
// // 驗證錯誤
// if tc.wantErr != nil {
// assert.ErrorIs(t, err, tc.wantErr)
// } else {
// assert.NoError(t, err)
// }
//
// // 驗證快取餘額
// gotBal := svc.CurrentBalance(tc.kind)
// assert.True(t, gotBal.Equal(tc.wantBalance), "balance = %s, want %s", gotBal, tc.wantBalance)
//
// // 驗證交易筆數
// assert.Len(t, svc.transactions, tc.wantTxCount)
// if tc.wantTxCount > 0 {
// tx := svc.transactions[0]
// assert.Equal(t, tc.orderID, tx.OrderID)
// assert.Equal(t, uid, tx.UID)
// assert.Equal(t, asset, tx.Asset)
// assert.True(t, tx.Amount.Equal(tc.amount))
// assert.True(t, tx.Balance.Equal(tc.wantBalance))
// }
// })
// }
//}
//
//func TestWalletService_PrepareTransactions(t *testing.T) {
// const (
// txID = int64(42)
// orderID = "order-123"
// brand = "brandX"
// bizNameStr = "business-test"
// )
// biz := wallet.BusinessName(bizNameStr)
//
// tests := []struct {
// name string
// initialTxs []entity.WalletTransaction
// wantCount int
// }{
// {
// name: "no transactions returns empty slice",
// initialTxs: nil,
// wantCount: 0,
// },
// {
// name: "single transaction is populated",
// initialTxs: []entity.WalletTransaction{
// {
// OrderID: "placeholder",
// UID: "u1",
// WalletType: wallet.AllTypes[0],
// Asset: "BTC",
// Amount: decimal.NewFromInt(5),
// Balance: decimal.NewFromInt(10),
// },
// },
// wantCount: 1,
// },
// {
// name: "multiple transactions are all populated",
// initialTxs: []entity.WalletTransaction{
// {UID: "u1", WalletType: wallet.AllTypes[1], Asset: "ETH", Amount: decimal.NewFromInt(1), Balance: decimal.NewFromInt(1)},
// {UID: "u2", WalletType: wallet.AllTypes[2], Asset: "TWD", Amount: decimal.NewFromInt(2), Balance: decimal.NewFromInt(2)},
// {UID: "u3", WalletType: wallet.AllTypes[3%len(wallet.AllTypes)], Asset: "USD", Amount: decimal.NewFromInt(3), Balance: decimal.NewFromInt(3)},
// },
// wantCount: 3,
// },
// }
//
// for _, tc := range tests {
// t.Run(tc.name, func(t *testing.T) {
// // 建立 WalletService 並注入初始交易
// svc := &WalletService{
// transactions: make([]entity.WalletTransaction, len(tc.initialTxs)),
// }
// copy(svc.transactions, tc.initialTxs)
//
// // 執行 PrepareTransactions
// result := svc.PrepareTransactions(txID, orderID, brand, biz)
//
// // 檢查回傳長度
// assert.Len(t, result, tc.wantCount)
// assert.Len(t, svc.transactions, tc.wantCount)
//
// // 驗證每筆交易的共用欄位是否正確
// for i := 0; i < tc.wantCount; i++ {
// tx := result[i]
// assert.Equal(t, txID, tx.TransactionID, "tx[%d].TransactionID", i)
// assert.Equal(t, orderID, tx.OrderID, "tx[%d].OrderID", i)
// assert.Equal(t, brand, tx.Brand, "tx[%d].Brand", i)
// assert.Equal(t, biz.ToInt8(), tx.BusinessType, "tx[%d].BusinessType", i)
// // 原有欄位保持不變
// assert.Equal(t, tc.initialTxs[i].UID, tx.UID, "tx[%d].UID unchanged", i)
// assert.Equal(t, tc.initialTxs[i].WalletType, tx.WalletType, "tx[%d].WalletType unchanged", i)
// assert.Equal(t, tc.initialTxs[i].Asset, tx.Asset, "tx[%d].Asset unchanged", i)
// assert.True(t, tx.Amount.Equal(tc.initialTxs[i].Amount), "tx[%d].Amount unchanged", i)
// assert.True(t, tx.Balance.Equal(tc.initialTxs[i].Balance), "tx[%d].Balance unchanged", i)
// }
// })
// }
//}
//
//func TestWalletService_PersistBalances(t *testing.T) {
// _, db, teardown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer teardown()
//
// // helper删除表、重建表、插入两笔 seed 数据,返回这两笔带 ID 的 slice
// seedWallets := func() []entity.Wallet {
// // DROP + AutoMigrate
// assert.NoError(t, db.Migrator().DropTable(&entity.Wallet{}))
// assert.NoError(t, db.AutoMigrate(&entity.Wallet{}))
//
// base := []entity.Wallet{
// {UID: "u1", Asset: "BTC", Brand: "b", Balance: decimal.NewFromInt(10), Type: wallet.AllTypes[0]},
// {UID: "u1", Asset: "BTC", Brand: "b", Balance: decimal.NewFromInt(20), Type: wallet.AllTypes[1]},
// }
// assert.NoError(t, db.Create(&base).Error)
// return base
// }
//
// type fields struct {
// updates map[wallet.Types]decimal.Decimal
// }
// type want struct {
// final []decimal.Decimal
// err bool
// }
//
// tests := []struct {
// name string
// fields fields
// want want
// }{
// {
// name: "no local balances → no change",
// fields: fields{updates: map[wallet.Types]decimal.Decimal{}},
// want: want{
// final: []decimal.Decimal{decimal.NewFromInt(10), decimal.NewFromInt(20)},
// err: false,
// },
// },
// {
// name: "update both balances",
// fields: fields{updates: map[wallet.Types]decimal.Decimal{
// wallet.AllTypes[0]: decimal.NewFromInt(15),
// wallet.AllTypes[1]: decimal.NewFromInt(5),
// }},
// want: want{
// final: []decimal.Decimal{decimal.NewFromInt(15), decimal.NewFromInt(5)},
// err: false,
// },
// },
// }
//
// for _, tc := range tests {
// t.Run(tc.name, func(t *testing.T) {
// // seed 并拿到带 ID 的记录
// seed := seedWallets()
//
// // 构造 localBalances只有 Type 在 updates 里的才覆盖
// localMap := make(map[wallet.Types]entity.Wallet, len(tc.fields.updates))
// for _, w := range seed {
// if nb, ok := tc.fields.updates[w.Type]; ok {
// localMap[w.Type] = entity.Wallet{
// ID: w.ID,
// Balance: nb,
// }
// }
// }
//
// // 初始化 service 并注入 localBalances
// svc := NewWalletService(db, "u1", "BTC").(*WalletService)
// svc.localBalances = localMap
//
// // 执行 PersistBalances
// err := svc.PersistBalances(context.Background())
// if tc.want.err {
// assert.Error(t, err)
// return
// }
// assert.NoError(t, err)
//
// // 重新查 DB按 ID 顺序比较余额
// var got []entity.Wallet
// assert.NoError(t, db.
// Where("uid = ?", "u1").
// Order("id").
// Find(&got).Error)
//
// assert.Len(t, got, len(seed))
// for i, w := range got {
// assert.Truef(t,
// w.Balance.Equal(tc.want.final[i]),
// "第 %d 条记录 (id=%d) 余额 = %s, 期望 %s",
// i, w.ID, w.Balance, tc.want.final[i],
// )
// }
// })
// }
//}
//
//func TestWalletService_PersistOrderBalances(t *testing.T) {
// _, db, teardown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer teardown()
//
// // helper重建 transaction 表並 seed 資料
// seedTransactions := func() []entity.Transaction {
// // DROP + AutoMigrate
// _ = db.Migrator().DropTable(&entity.Transaction{})
// assert.NoError(t, db.AutoMigrate(&entity.Transaction{}))
//
// // 插入兩筆 transaction
// base := []entity.Transaction{
// {PostTransferBalance: decimal.NewFromInt(100)},
// {PostTransferBalance: decimal.NewFromInt(200)},
// }
// assert.NoError(t, db.Create(&base).Error)
// return base
// }
//
// type fields struct {
// updates map[int64]decimal.Decimal
// }
// type want struct {
// final []decimal.Decimal
// err bool
// }
//
// tests := []struct {
// name string
// fields fields
// want want
// }{
// {
// name: "no local order balances → no change",
// fields: fields{updates: map[int64]decimal.Decimal{}},
// want: want{
// final: []decimal.Decimal{decimal.NewFromInt(100), decimal.NewFromInt(200)},
// err: false,
// },
// },
// {
// name: "update first order balance only",
// fields: fields{updates: map[int64]decimal.Decimal{
// 1: decimal.NewFromInt(150),
// }},
// want: want{
// final: []decimal.Decimal{decimal.NewFromInt(150), decimal.NewFromInt(200)},
// err: false,
// },
// },
// {
// name: "update both order balances",
// fields: fields{updates: map[int64]decimal.Decimal{
// 1: decimal.NewFromInt(110),
// 2: decimal.NewFromInt(220),
// }},
// want: want{
// final: []decimal.Decimal{decimal.NewFromInt(110), decimal.NewFromInt(220)},
// err: false,
// },
// },
// }
//
// for _, tc := range tests {
// t.Run(tc.name, func(t *testing.T) {
// // seed 資料並拿回 slice包含自動產生的 ID
// seed := seedTransactions()
//
// // 建構 localOrderBalanceskey 是 seed[i].ID
// localMap := make(map[int64]decimal.Decimal, len(tc.fields.updates))
// for id, newBal := range tc.fields.updates {
// localMap[id] = newBal
// }
//
// // 初始化 service、注入 localOrderBalances
// svc := NewWalletService(db, "u1", "BTC").(*WalletService)
// svc.localOrderBalances = localMap
//
// // 執行 PersistOrderBalances
// err := svc.PersistOrderBalances(context.Background())
// if tc.want.err {
// assert.Error(t, err)
// return
// }
// assert.NoError(t, err)
//
// // 依照 seed 的 ID 順序讀回資料庫
// var got []entity.Transaction
// assert.NoError(t, db.
// Where("1 = 1").
// Order("id").
// Find(&got).Error)
// // 長度要和 seed 相同
// assert.Len(t, got, len(seed))
// // 比對每一筆 balance
// for i, tr := range got {
// assert.Truef(t,
// tr.PostTransferBalance.Equal(tc.want.final[i]),
// "第 %d 筆交易(id=%d) balance = %s, want %s",
// i, tr.ID, tr.PostTransferBalance, tc.want.final[i],
// )
// }
// })
// }
//}
//
//func TestWalletService_HasAvailableBalance(t *testing.T) {
// _, db, teardown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer teardown()
//
// // 建表
// createTable := `
// CREATE TABLE IF NOT EXISTS wallet (
// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
// brand VARCHAR(50) NOT NULL,
// uid VARCHAR(64) NOT NULL,
// asset VARCHAR(32) NOT NULL,
// balance DECIMAL(30,18) NOT NULL DEFAULT 0,
// type TINYINT NOT NULL,
// create_at INTEGER NOT NULL DEFAULT 0,
// update_at INTEGER NOT NULL DEFAULT 0,
// PRIMARY KEY (id),
// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
// assert.NoError(t, db.Exec(createTable).Error)
//
// type seedRow struct {
// Brand string
// UID string
// Asset string
// Type wallet.Types
// Balance decimal.Decimal
// }
//
// tests := []struct {
// name string
// uid string
// asset string
// seed []seedRow
// wantExist bool
// }{
// {
// name: "無任何紀錄",
// uid: "user1",
// asset: "BTC",
// seed: nil,
// wantExist: false,
// },
// {
// name: "只有其他類型錢包",
// uid: "user1",
// asset: "BTC",
// seed: []seedRow{
// {"", "user1", "BTC", wallet.TypeFreeze, decimal.Zero},
// {"", "user1", "BTC", wallet.TypeUnconfirmed, decimal.Zero},
// },
// wantExist: false,
// },
// {
// name: "已有可用錢包",
// uid: "user1",
// asset: "BTC",
// seed: []seedRow{
// {"", "user1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(10)},
// },
// wantExist: true,
// },
// {
// name: "不同 UID 不算",
// uid: "user2",
// asset: "BTC",
// seed: []seedRow{
// {"", "user1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)},
// },
// wantExist: false,
// },
// {
// name: "不同 Asset 不算",
// uid: "user1",
// asset: "ETH",
// seed: []seedRow{
// {"", "user1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)},
// },
// wantExist: false,
// },
// }
//
// for _, tc := range tests {
// t.Run(tc.name, func(t *testing.T) {
// // 每個子測試前都清空 wallet table
// assert.NoError(t, db.Exec("DELETE FROM wallet").Error)
//
// // seed 資料到 wallet
// for _, r := range tc.seed {
// w := entity.Wallet{
// Brand: r.Brand,
// UID: r.UID,
// Asset: r.Asset,
// Type: r.Type,
// Balance: r.Balance,
// }
// assert.NoError(t, db.Create(&w).Error)
// }
//
// // 建 service
// svc := NewWalletService(db, tc.uid, tc.asset).(*WalletService)
// got, err := svc.HasAvailableBalance(context.Background())
// assert.NoError(t, err)
// assert.Equal(t, tc.wantExist, got)
// })
// }
//}
//
//
//
//func TestWalletService_GetBalancesForUpdate(t *testing.T) {
// db, tearDown := SetupTestDB(t)
// defer tearDown()
//
// type seedRow struct {
// Brand string
// UID string
// Asset string
// Type wallet.Types
// Balance decimal.Decimal
// }
//
// tests := []struct {
// name string
// uid string
// asset string
// seed []seedRow
// kinds []wallet.Types
// wantIDs []int64
// expectErr bool
// }{
// {
// name: "查詢空結果",
// uid: "u1",
// asset: "BTC",
// seed: nil,
// kinds: []wallet.Types{wallet.TypeAvailable},
// wantIDs: nil,
// expectErr: false,
// },
// {
// name: "單一類型查詢",
// uid: "u1",
// asset: "BTC",
// seed: []seedRow{
// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)},
// {"", "u1", "BTC", wallet.TypeFreeze, decimal.NewFromInt(2)},
// },
// kinds: []wallet.Types{wallet.TypeFreeze},
// wantIDs: []int64{2},
// expectErr: false,
// },
// {
// name: "多類型查詢",
// uid: "u1",
// asset: "BTC",
// seed: []seedRow{
// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)},
// {"", "u1", "BTC", wallet.TypeFreeze, decimal.NewFromInt(2)},
// {"", "u1", "BTC", wallet.TypeUnconfirmed, decimal.NewFromInt(3)},
// },
// kinds: []wallet.Types{wallet.TypeAvailable, wallet.TypeUnconfirmed},
// wantIDs: []int64{3, 5},
// expectErr: false,
// },
// {
// name: "不同 UID 不列入",
// uid: "u2",
// asset: "BTC",
// seed: []seedRow{
// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)},
// },
// kinds: []wallet.Types{wallet.TypeAvailable},
// wantIDs: nil,
// expectErr: false,
// },
// {
// name: "不同 Asset 不列入",
// uid: "u1",
// asset: "ETH",
// seed: []seedRow{
// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)},
// },
// kinds: []wallet.Types{wallet.TypeAvailable},
// wantIDs: nil,
// expectErr: false,
// },
// }
//
// for _, tc := range tests {
// t.Run(tc.name, func(t *testing.T) {
// // 清空資料
// assert.NoError(t, db.Exec("DELETE FROM wallet").Error)
// // Seed
// for _, r := range tc.seed {
// w := entity.Wallet{
// Brand: r.Brand,
// UID: r.UID,
// Asset: r.Asset,
// Type: r.Type,
// Balance: r.Balance,
// }
// // Create will auto-assign incremental IDs
// assert.NoError(t, db.Create(&w).Error)
// }
//
// // 建 Service
// svc := NewWalletService(db, tc.uid, tc.asset).(*WalletService)
// got, err := svc.GetBalancesForUpdate(context.Background(), tc.kinds)
// if tc.expectErr {
// assert.Error(t, err)
// return
// }
// assert.NoError(t, err)
//
// // 取回 IDs 並排序
// var gotIDs []int64
// for _, w := range got {
// gotIDs = append(gotIDs, w.ID)
// // localBalances 應該被更新
// assert.Equal(t, w, svc.localBalances[w.Type])
// }
// assert.Equal(t, tc.wantIDs, gotIDs)
// })
// }
//}
//
//func TestWalletService_GetOrderBalance(t *testing.T) {
// _, db, tearDown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer tearDown()
//
// // create transaction table
// create := `
// CREATE TABLE IF NOT EXISTS transaction (
// id BIGINT AUTO_INCREMENT PRIMARY KEY,
// order_id VARCHAR(64),
// transaction_id VARCHAR(64),
// brand VARCHAR(32),
// uid VARCHAR(64),
// to_uid VARCHAR(64),
// type TINYINT,
// business_type TINYINT,
// asset VARCHAR(32),
// amount DECIMAL(30,18),
// before_balance DECIMAL(30,18),
// post_transfer_balance DECIMAL(30,18),
// status TINYINT,
// create_at BIGINT,
// due_time BIGINT
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
// assert.NoError(t, db.Exec(create).Error)
//
// // seed a row
// now := time.Now().Unix()
// seed := `
// INSERT INTO transaction
// (order_id, transaction_id, brand, uid, to_uid,
// type, business_type, asset, amount,
// before_balance, post_transfer_balance,
// status, create_at, due_time)
// VALUES
// (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// _, err = db.Exec(seed,
// "order1", "tx-uuid-1", "brandA", "user1", "user2",
// 1, 2, "BTC", "42.5",
// "10.0", "52.5",
// 1, now, now+3600,
// ).RowsAffected, db.Error
// assert.NoError(t, err)
//
// svc := NewWalletService(db, "user1", "BTC")
//
// type want struct {
// tx entity.Transaction
// errIsNF bool
// }
//
// tests := []struct {
// name string
// orderID string
// want want
// }{
// {
// name: "found existing transaction",
// orderID: "order1",
// want: want{
// tx: entity.Transaction{
// ID: 1,
// OrderID: "order1",
// TransactionID: "tx-uuid-1",
// Brand: "brandA",
// UID: "user1",
// ToUID: "user2",
// TxType: wallet.TxType(1),
// BusinessType: int8(2),
// Asset: "BTC",
// Amount: decimal.RequireFromString("42.5"),
// BeforeBalance: decimal.RequireFromString("10.0"),
// PostTransferBalance: decimal.RequireFromString("52.5"),
// Status: wallet.Enable(1),
// CreateAt: now,
// DueTime: now + 3600,
// },
// errIsNF: false,
// },
// },
// {
// name: "not found returns ErrRecordNotFound",
// orderID: "order2",
// want: want{errIsNF: true},
// },
// }
//
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// got, err := svc.GetOrderBalance(context.Background(), tt.orderID)
// if tt.want.errIsNF {
// assert.Error(t, err)
// assert.True(t, errors.Is(err, repository.ErrRecordNotFound))
// } else {
// assert.NoError(t, err)
// // compare all fields except those autoset by GORM (e.g. pointers)
// assert.Equal(t, tt.want.tx.ID, got.ID)
// assert.Equal(t, tt.want.tx.OrderID, got.OrderID)
// assert.Equal(t, tt.want.tx.TransactionID, got.TransactionID)
// assert.Equal(t, tt.want.tx.Brand, got.Brand)
// assert.Equal(t, tt.want.tx.UID, got.UID)
// assert.Equal(t, tt.want.tx.ToUID, got.ToUID)
// assert.Equal(t, tt.want.tx.TxType, got.TxType)
// assert.Equal(t, tt.want.tx.BusinessType, got.BusinessType)
// assert.Equal(t, tt.want.tx.Asset, got.Asset)
// assert.True(t, tt.want.tx.Amount.Equal(got.Amount))
// assert.True(t, tt.want.tx.BeforeBalance.Equal(got.BeforeBalance))
// assert.True(t, tt.want.tx.PostTransferBalance.Equal(got.PostTransferBalance))
// assert.Equal(t, tt.want.tx.Status, got.Status)
// assert.Equal(t, tt.want.tx.CreateAt, got.CreateAt)
// assert.Equal(t, tt.want.tx.DueTime, got.DueTime)
// }
// })
// }
//}
//
//func TestWalletService_GetOrderBalanceForUpdate(t *testing.T) {
// // setup container + DB + repo
// _, db, tearDown, err := SetupTestWalletRepository()
// assert.NoError(t, err)
// defer tearDown()
//
// // create transaction table
// create := `
// CREATE TABLE IF NOT EXISTS transaction (
// id BIGINT AUTO_INCREMENT PRIMARY KEY,
// order_id VARCHAR(64),
// transaction_id VARCHAR(64),
// brand VARCHAR(32),
// uid VARCHAR(64),
// to_uid VARCHAR(64),
// type TINYINT,
// business_type TINYINT,
// asset VARCHAR(32),
// amount DECIMAL(30,18),
// before_balance DECIMAL(30,18),
// post_transfer_balance DECIMAL(30,18),
// status TINYINT,
// create_at BIGINT,
// due_time BIGINT
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
// assert.NoError(t, db.Exec(create).Error)
//
// // seed one row
// now := time.Now().Unix()
// _, err = db.Exec(
// `INSERT INTO transaction
// (order_id, transaction_id, brand, uid, to_uid, type, business_type, asset,
// amount, before_balance, post_transfer_balance, status, create_at, due_time)
// VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
// "ordX", "tx-XYZ", "brandZ", "u1", "u2",
// 1, 2, "ETH",
// "100.0", "50.0", "150.0",
// 1, now, now+600,
// ).RowsAffected, db.Error
// assert.NoError(t, err)
//
// // service under test
// svc := NewWalletService(db, "u1", "ETH")
//
// type want struct {
// tx entity.Transaction
// errIsNF bool
// }
//
// cases := []struct {
// name string
// orderID string
// want want
// }{
// {
// name: "found and locks",
// orderID: "ordX",
// want: want{
// tx: entity.Transaction{
// ID: 1,
// OrderID: "ordX",
// TransactionID: "tx-XYZ",
// Brand: "brandZ",
// UID: "u1",
// ToUID: "u2",
// TxType: wallet.TxType(1),
// BusinessType: int8(2),
// Asset: "ETH",
// Amount: decimal.RequireFromString("100.0"),
// BeforeBalance: decimal.RequireFromString("50.0"),
// PostTransferBalance: decimal.RequireFromString("150.0"),
// Status: wallet.Enable(1),
// CreateAt: now,
// DueTime: now + 600,
// },
// errIsNF: false,
// },
// },
// {
// name: "missing row returns ErrRecordNotFound",
// orderID: "ordY",
// want: want{errIsNF: true},
// },
// }
//
// for _, tt := range cases {
// t.Run(tt.name, func(t *testing.T) {
// got, err := svc.GetOrderBalanceForUpdate(context.Background(), tt.orderID)
// if tt.want.errIsNF {
// assert.Error(t, err)
// return
// }
// assert.NoError(t, err)
//
// // verify all fields
// expected := tt.want.tx
// assert.Equal(t, expected.ID, got.ID)
// assert.Equal(t, expected.OrderID, got.OrderID)
// assert.Equal(t, expected.TransactionID, got.TransactionID)
// assert.Equal(t, expected.Brand, got.Brand)
// assert.Equal(t, expected.UID, got.UID)
// assert.Equal(t, expected.ToUID, got.ToUID)
// assert.Equal(t, expected.TxType, got.TxType)
// assert.Equal(t, expected.BusinessType, got.BusinessType)
// assert.Equal(t, expected.Asset, got.Asset)
// assert.True(t, expected.Amount.Equal(got.Amount))
// assert.True(t, expected.BeforeBalance.Equal(got.BeforeBalance))
// assert.True(t, expected.PostTransferBalance.Equal(got.PostTransferBalance))
// assert.Equal(t, expected.Status, got.Status)
// assert.Equal(t, expected.CreateAt, got.CreateAt)
// assert.Equal(t, expected.DueTime, got.DueTime)
//
// // verify local cache was set
// cached, ok := svc.(*WalletService).localOrderBalances[got.ID]
// assert.True(t, ok, "expected localOrderBalances to contain key")
// assert.True(t, expected.PostTransferBalance.Equal(cached))
// })
// }
//}