Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
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)
|
||
}
|