Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
//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)
|
||||
}
|
||||
Reference in New Issue
Block a user