264 lines
8.2 KiB
Go
264 lines
8.2 KiB
Go
//go:build unit
|
||||
|
|
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type balanceUserRepoStub struct {
|
|||
|
|
*userRepoStub
|
|||
|
|
adjustErr error
|
|||
|
|
// changes 记录每次原子余额变更,顺序与调用顺序一致。
|
|||
|
|
changes []BalanceChange
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *balanceUserRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
|
|||
|
|
return s.apply(func(current float64) float64 { return current + delta })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *balanceUserRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
|
|||
|
|
return s.apply(func(float64) float64 { return value })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *balanceUserRepoStub) apply(next func(current float64) float64) (BalanceChange, error) {
|
|||
|
|
if s.adjustErr != nil {
|
|||
|
|
return BalanceChange{}, s.adjustErr
|
|||
|
|
}
|
|||
|
|
if s.userRepoStub == nil || s.userRepoStub.user == nil {
|
|||
|
|
return BalanceChange{}, ErrUserNotFound
|
|||
|
|
}
|
|||
|
|
change := BalanceChange{Old: s.userRepoStub.user.Balance}
|
|||
|
|
change.New = next(change.Old)
|
|||
|
|
if change.New < 0 {
|
|||
|
|
return change, ErrBalanceNegative
|
|||
|
|
}
|
|||
|
|
s.userRepoStub.user.Balance = change.New
|
|||
|
|
s.changes = append(s.changes, change)
|
|||
|
|
return change, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type balanceRedeemRepoStub struct {
|
|||
|
|
*redeemRepoStub
|
|||
|
|
created []*RedeemCode
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *balanceRedeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
|
|||
|
|
if code == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
clone := *code
|
|||
|
|
s.created = append(s.created, &clone)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type authCacheInvalidatorStub struct {
|
|||
|
|
userIDs []int64
|
|||
|
|
groupIDs []int64
|
|||
|
|
keys []string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type adminRechargeAffiliateAccruerStub struct {
|
|||
|
|
calls []adminRechargeAffiliateAccrual
|
|||
|
|
rebate float64
|
|||
|
|
err error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type adminRechargeAffiliateAccrual struct {
|
|||
|
|
userID int64
|
|||
|
|
amount float64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *adminRechargeAffiliateAccruerStub) AccrueInviteRebate(_ context.Context, userID int64, amount float64) (float64, error) {
|
|||
|
|
s.calls = append(s.calls, adminRechargeAffiliateAccrual{userID: userID, amount: amount})
|
|||
|
|
return s.rebate, s.err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func adminRechargeSettingService(enabled bool) *SettingService {
|
|||
|
|
values := map[string]string{}
|
|||
|
|
if enabled {
|
|||
|
|
values[SettingKeyAffiliateAdminRechargeEnabled] = "true"
|
|||
|
|
}
|
|||
|
|
return NewSettingService(&settingRepoStub{values: values}, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *authCacheInvalidatorStub) InvalidateAuthCacheByKey(ctx context.Context, key string) {
|
|||
|
|
s.keys = append(s.keys, key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *authCacheInvalidatorStub) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) {
|
|||
|
|
s.userIDs = append(s.userIDs, userID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *authCacheInvalidatorStub) InvalidateAuthCacheByGroupID(ctx context.Context, groupID int64) {
|
|||
|
|
s.groupIDs = append(s.groupIDs, groupID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 管理员调账必须走原子的 AdjustBalance/SetBalance,而不是"读余额→算新值→整行写回",
|
|||
|
|
// 后者会把并发的计费扣款覆盖掉。userRepoStub.Update 对未预期的调用会 panic,
|
|||
|
|
// 因此这里同时证明它没被走到。
|
|||
|
|
func TestAdminService_UpdateUserBalance_UsesAtomicPrimitives(t *testing.T) {
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
operation string
|
|||
|
|
amount float64
|
|||
|
|
want BalanceChange
|
|||
|
|
}{
|
|||
|
|
{name: "add", operation: "add", amount: 5, want: BalanceChange{Old: 10, New: 15}},
|
|||
|
|
{name: "subtract", operation: "subtract", amount: 4, want: BalanceChange{Old: 10, New: 6}},
|
|||
|
|
{name: "set", operation: "set", amount: 2, want: BalanceChange{Old: 10, New: 2}},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
|
|||
|
|
svc := &adminServiceImpl{
|
|||
|
|
userRepo: repo,
|
|||
|
|
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
user, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Equal(t, []BalanceChange{tt.want}, repo.changes)
|
|||
|
|
require.Equal(t, tt.want.New, user.Balance)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestAdminService_UpdateUserBalance_RejectsNegativeResult(t *testing.T) {
|
|||
|
|
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 3}}}
|
|||
|
|
svc := &adminServiceImpl{
|
|||
|
|
userRepo: repo,
|
|||
|
|
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, err := svc.UpdateUserBalance(context.Background(), 7, 4, "subtract", "")
|
|||
|
|
require.Error(t, err)
|
|||
|
|
require.Contains(t, err.Error(), "balance cannot be negative")
|
|||
|
|
require.Empty(t, repo.changes, "refused adjustment must not be applied")
|
|||
|
|
require.Equal(t, 3.0, repo.userRepoStub.user.Balance)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestAdminService_UpdateUserBalance_RejectsUnknownOperation(t *testing.T) {
|
|||
|
|
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
|
|||
|
|
svc := &adminServiceImpl{
|
|||
|
|
userRepo: repo,
|
|||
|
|
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, err := svc.UpdateUserBalance(context.Background(), 7, 1, "multiply", "")
|
|||
|
|
require.Error(t, err)
|
|||
|
|
require.Empty(t, repo.changes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestAdminService_UpdateUserBalance_InvalidatesAuthCache(t *testing.T) {
|
|||
|
|
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
|||
|
|
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
|||
|
|
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
|||
|
|
invalidator := &authCacheInvalidatorStub{}
|
|||
|
|
svc := &adminServiceImpl{
|
|||
|
|
userRepo: repo,
|
|||
|
|
redeemCodeRepo: redeemRepo,
|
|||
|
|
authCacheInvalidator: invalidator,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, err := svc.UpdateUserBalance(context.Background(), 7, 5, "add", "")
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Equal(t, []int64{7}, invalidator.userIDs)
|
|||
|
|
require.Len(t, redeemRepo.created, 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestAdminService_UpdateUserBalance_NoChangeNoInvalidate(t *testing.T) {
|
|||
|
|
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
|||
|
|
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
|||
|
|
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
|||
|
|
invalidator := &authCacheInvalidatorStub{}
|
|||
|
|
svc := &adminServiceImpl{
|
|||
|
|
userRepo: repo,
|
|||
|
|
redeemCodeRepo: redeemRepo,
|
|||
|
|
authCacheInvalidator: invalidator,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, err := svc.UpdateUserBalance(context.Background(), 7, 10, "set", "")
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Empty(t, invalidator.userIDs)
|
|||
|
|
require.Empty(t, redeemRepo.created)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestAdminService_UpdateUserBalance_AdminRechargeAffiliateRebate(t *testing.T) {
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
enabled bool
|
|||
|
|
operation string
|
|||
|
|
amount float64
|
|||
|
|
wantCalls []adminRechargeAffiliateAccrual
|
|||
|
|
}{
|
|||
|
|
{
|
|||
|
|
name: "disabled by default",
|
|||
|
|
operation: "add",
|
|||
|
|
amount: 5,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "enabled add",
|
|||
|
|
enabled: true,
|
|||
|
|
operation: "add",
|
|||
|
|
amount: 0.1,
|
|||
|
|
wantCalls: []adminRechargeAffiliateAccrual{{userID: 7, amount: 0.1}},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "enabled set increase",
|
|||
|
|
enabled: true,
|
|||
|
|
operation: "set",
|
|||
|
|
amount: 15,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "enabled subtract",
|
|||
|
|
enabled: true,
|
|||
|
|
operation: "subtract",
|
|||
|
|
amount: 5,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
|||
|
|
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
|||
|
|
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
|||
|
|
affiliate := &adminRechargeAffiliateAccruerStub{}
|
|||
|
|
svc := &adminServiceImpl{
|
|||
|
|
userRepo: repo,
|
|||
|
|
redeemCodeRepo: redeemRepo,
|
|||
|
|
settingService: adminRechargeSettingService(tt.enabled),
|
|||
|
|
affiliateService: affiliate,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Equal(t, tt.wantCalls, affiliate.calls)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestAdminService_UpdateUserBalance_AffiliateFailureDoesNotRollbackRecharge(t *testing.T) {
|
|||
|
|
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
|||
|
|
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
|||
|
|
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
|||
|
|
affiliate := &adminRechargeAffiliateAccruerStub{err: errors.New("affiliate unavailable")}
|
|||
|
|
svc := &adminServiceImpl{
|
|||
|
|
userRepo: repo,
|
|||
|
|
redeemCodeRepo: redeemRepo,
|
|||
|
|
settingService: adminRechargeSettingService(true),
|
|||
|
|
affiliateService: affiliate,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
user, err := svc.UpdateUserBalance(context.Background(), 7, 5, "add", "")
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Equal(t, 15.0, user.Balance)
|
|||
|
|
require.Equal(t, []adminRechargeAffiliateAccrual{{userID: 7, amount: 5}}, affiliate.calls)
|
|||
|
|
require.Len(t, redeemRepo.created, 1)
|
|||
|
|
}
|