| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- //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)
- }
|