Files
sub2api/backend/internal/service/admin_service_update_balance_test.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

264 lines
8.2 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.
//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)
}