test(删除): 添加删除单测并修复中间件测试
新增 AdminService 删除路径单元测试与规范场景更新\n同步调整 Google API Key 中间件测试桩与签名
This commit is contained in:
@@ -20,26 +20,39 @@ type fakeApiKeyRepo struct {
|
||||
getByKey func(ctx context.Context, key string) (*service.ApiKey, error)
|
||||
}
|
||||
|
||||
func (f fakeApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error { return errors.New("not implemented") }
|
||||
func (f fakeApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) GetOwnerID(ctx context.Context, id int64) (int64, error) {
|
||||
return 0, errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) {
|
||||
if f.getByKey == nil {
|
||||
return nil, errors.New("unexpected call")
|
||||
}
|
||||
return f.getByKey(ctx, key)
|
||||
}
|
||||
func (f fakeApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error { return errors.New("not implemented") }
|
||||
func (f fakeApiKeyRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") }
|
||||
func (f fakeApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { return 0, errors.New("not implemented") }
|
||||
func (f fakeApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { return false, errors.New("not implemented") }
|
||||
func (f fakeApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
||||
return 0, errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) {
|
||||
return false, errors.New("not implemented")
|
||||
}
|
||||
func (f fakeApiKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
@@ -81,7 +94,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_MissingKey(t *testing.T) {
|
||||
return nil, errors.New("should not be called")
|
||||
},
|
||||
})
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil))
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
|
||||
r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
|
||||
@@ -105,7 +118,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_InvalidKey(t *testing.T) {
|
||||
return nil, service.ErrApiKeyNotFound
|
||||
},
|
||||
})
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil))
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
|
||||
r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
|
||||
@@ -130,7 +143,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_RepoError(t *testing.T) {
|
||||
return nil, errors.New("db down")
|
||||
},
|
||||
})
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil))
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
|
||||
r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
|
||||
@@ -163,7 +176,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_DisabledKey(t *testing.T) {
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil))
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
|
||||
r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
|
||||
@@ -197,7 +210,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_InsufficientBalance(t *testing.T) {
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil))
|
||||
r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{}))
|
||||
r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
|
||||
|
||||
448
backend/internal/service/admin_service_delete_test.go
Normal file
448
backend/internal/service/admin_service_delete_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type userRepoStub struct {
|
||||
user *User
|
||||
getErr error
|
||||
deleteErr error
|
||||
deletedIDs []int64
|
||||
}
|
||||
|
||||
func (s *userRepoStub) Create(ctx context.Context, user *User) error {
|
||||
panic("unexpected Create call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
if s.user == nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return s.user, nil
|
||||
}
|
||||
|
||||
func (s *userRepoStub) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
panic("unexpected GetByEmail call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) GetFirstAdmin(ctx context.Context) (*User, error) {
|
||||
panic("unexpected GetFirstAdmin call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) Update(ctx context.Context, user *User) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
s.deletedIDs = append(s.deletedIDs, id)
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
func (s *userRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected List call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, status, role, search string) ([]User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListWithFilters call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) UpdateBalance(ctx context.Context, id int64, amount float64) error {
|
||||
panic("unexpected UpdateBalance call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) DeductBalance(ctx context.Context, id int64, amount float64) error {
|
||||
panic("unexpected DeductBalance call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
|
||||
panic("unexpected UpdateConcurrency call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
panic("unexpected ExistsByEmail call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
|
||||
panic("unexpected RemoveGroupFromAllowedGroups call")
|
||||
}
|
||||
|
||||
type groupRepoStub struct {
|
||||
affectedUserIDs []int64
|
||||
deleteErr error
|
||||
deleteCalls []int64
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) Create(ctx context.Context, group *Group) error {
|
||||
panic("unexpected Create call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) GetByID(ctx context.Context, id int64) (*Group, error) {
|
||||
panic("unexpected GetByID call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) Update(ctx context.Context, group *Group) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) DeleteCascade(ctx context.Context, id int64) ([]int64, error) {
|
||||
s.deleteCalls = append(s.deleteCalls, id)
|
||||
return s.affectedUserIDs, s.deleteErr
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
|
||||
panic("unexpected List call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListWithFilters call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) ListActive(ctx context.Context) ([]Group, error) {
|
||||
panic("unexpected ListActive call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) {
|
||||
panic("unexpected ListActiveByPlatform call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
panic("unexpected ExistsByName call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
||||
panic("unexpected GetAccountCount call")
|
||||
}
|
||||
|
||||
func (s *groupRepoStub) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||
panic("unexpected DeleteAccountGroupsByGroupID call")
|
||||
}
|
||||
|
||||
type proxyRepoStub struct {
|
||||
deleteErr error
|
||||
deletedIDs []int64
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) Create(ctx context.Context, proxy *Proxy) error {
|
||||
panic("unexpected Create call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) GetByID(ctx context.Context, id int64) (*Proxy, error) {
|
||||
panic("unexpected GetByID call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) Update(ctx context.Context, proxy *Proxy) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
s.deletedIDs = append(s.deletedIDs, id)
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("unexpected List call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListWithFilters call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) ListActive(ctx context.Context) ([]Proxy, error) {
|
||||
panic("unexpected ListActive call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) ListActiveWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
|
||||
panic("unexpected ListActiveWithAccountCount call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error) {
|
||||
panic("unexpected ExistsByHostPortAuth call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
||||
panic("unexpected CountAccountsByProxyID call")
|
||||
}
|
||||
|
||||
type redeemRepoStub struct {
|
||||
deleteErrByID map[int64]error
|
||||
deletedIDs []int64
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
|
||||
panic("unexpected Create call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) CreateBatch(ctx context.Context, codes []RedeemCode) error {
|
||||
panic("unexpected CreateBatch call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
|
||||
panic("unexpected GetByID call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) GetByCode(ctx context.Context, code string) (*RedeemCode, error) {
|
||||
panic("unexpected GetByCode call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) Update(ctx context.Context, code *RedeemCode) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
s.deletedIDs = append(s.deletedIDs, id)
|
||||
if s.deleteErrByID != nil {
|
||||
if err, ok := s.deleteErrByID[id]; ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) Use(ctx context.Context, id, userID int64) error {
|
||||
panic("unexpected Use call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) {
|
||||
panic("unexpected List call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, codeType, status, search string) ([]RedeemCode, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListWithFilters call")
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) ListByUser(ctx context.Context, userID int64, limit int) ([]RedeemCode, error) {
|
||||
panic("unexpected ListByUser call")
|
||||
}
|
||||
|
||||
type subscriptionInvalidateCall struct {
|
||||
userID int64
|
||||
groupID int64
|
||||
}
|
||||
|
||||
type billingCacheStub struct {
|
||||
invalidations chan subscriptionInvalidateCall
|
||||
}
|
||||
|
||||
func newBillingCacheStub(buffer int) *billingCacheStub {
|
||||
return &billingCacheStub{invalidations: make(chan subscriptionInvalidateCall, buffer)}
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) GetUserBalance(ctx context.Context, userID int64) (float64, error) {
|
||||
panic("unexpected GetUserBalance call")
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) SetUserBalance(ctx context.Context, userID int64, balance float64) error {
|
||||
panic("unexpected SetUserBalance call")
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) DeductUserBalance(ctx context.Context, userID int64, amount float64) error {
|
||||
panic("unexpected DeductUserBalance call")
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) InvalidateUserBalance(ctx context.Context, userID int64) error {
|
||||
panic("unexpected InvalidateUserBalance call")
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) GetSubscriptionCache(ctx context.Context, userID, groupID int64) (*SubscriptionCacheData, error) {
|
||||
panic("unexpected GetSubscriptionCache call")
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) SetSubscriptionCache(ctx context.Context, userID, groupID int64, data *SubscriptionCacheData) error {
|
||||
panic("unexpected SetSubscriptionCache call")
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) UpdateSubscriptionUsage(ctx context.Context, userID, groupID int64, cost float64) error {
|
||||
panic("unexpected UpdateSubscriptionUsage call")
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) InvalidateSubscriptionCache(ctx context.Context, userID, groupID int64) error {
|
||||
s.invalidations <- subscriptionInvalidateCall{userID: userID, groupID: groupID}
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForInvalidations(t *testing.T, ch <-chan subscriptionInvalidateCall, expected int) []subscriptionInvalidateCall {
|
||||
t.Helper()
|
||||
calls := make([]subscriptionInvalidateCall, 0, expected)
|
||||
timeout := time.After(2 * time.Second)
|
||||
for len(calls) < expected {
|
||||
select {
|
||||
case call := <-ch:
|
||||
calls = append(calls, call)
|
||||
case <-timeout:
|
||||
t.Fatalf("timeout waiting for %d invalidations, got %d", expected, len(calls))
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteUser_Success(t *testing.T) {
|
||||
repo := &userRepoStub{user: &User{ID: 7, Role: RoleUser}}
|
||||
svc := &adminServiceImpl{userRepo: repo}
|
||||
|
||||
err := svc.DeleteUser(context.Background(), 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{7}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteUser_NotFound(t *testing.T) {
|
||||
repo := &userRepoStub{getErr: ErrUserNotFound}
|
||||
svc := &adminServiceImpl{userRepo: repo}
|
||||
|
||||
err := svc.DeleteUser(context.Background(), 404)
|
||||
require.ErrorIs(t, err, ErrUserNotFound)
|
||||
require.Empty(t, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteUser_AdminGuard(t *testing.T) {
|
||||
repo := &userRepoStub{user: &User{ID: 1, Role: RoleAdmin}}
|
||||
svc := &adminServiceImpl{userRepo: repo}
|
||||
|
||||
err := svc.DeleteUser(context.Background(), 1)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "cannot delete admin user")
|
||||
require.Empty(t, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteUser_DeleteError(t *testing.T) {
|
||||
deleteErr := errors.New("delete failed")
|
||||
repo := &userRepoStub{
|
||||
user: &User{ID: 9, Role: RoleUser},
|
||||
deleteErr: deleteErr,
|
||||
}
|
||||
svc := &adminServiceImpl{userRepo: repo}
|
||||
|
||||
err := svc.DeleteUser(context.Background(), 9)
|
||||
require.ErrorIs(t, err, deleteErr)
|
||||
require.Equal(t, []int64{9}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteGroup_Success_WithCacheInvalidation(t *testing.T) {
|
||||
cache := newBillingCacheStub(2)
|
||||
repo := &groupRepoStub{affectedUserIDs: []int64{11, 12}}
|
||||
svc := &adminServiceImpl{
|
||||
groupRepo: repo,
|
||||
billingCacheService: &BillingCacheService{cache: cache},
|
||||
}
|
||||
|
||||
err := svc.DeleteGroup(context.Background(), 5)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{5}, repo.deleteCalls)
|
||||
|
||||
calls := waitForInvalidations(t, cache.invalidations, 2)
|
||||
require.ElementsMatch(t, []subscriptionInvalidateCall{
|
||||
{userID: 11, groupID: 5},
|
||||
{userID: 12, groupID: 5},
|
||||
}, calls)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteGroup_NotFound(t *testing.T) {
|
||||
repo := &groupRepoStub{deleteErr: ErrGroupNotFound}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
err := svc.DeleteGroup(context.Background(), 99)
|
||||
require.ErrorIs(t, err, ErrGroupNotFound)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteGroup_Error(t *testing.T) {
|
||||
deleteErr := errors.New("delete failed")
|
||||
repo := &groupRepoStub{deleteErr: deleteErr}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
err := svc.DeleteGroup(context.Background(), 42)
|
||||
require.ErrorIs(t, err, deleteErr)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteProxy_Success(t *testing.T) {
|
||||
repo := &proxyRepoStub{}
|
||||
svc := &adminServiceImpl{proxyRepo: repo}
|
||||
|
||||
err := svc.DeleteProxy(context.Background(), 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{7}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteProxy_Idempotent(t *testing.T) {
|
||||
repo := &proxyRepoStub{}
|
||||
svc := &adminServiceImpl{proxyRepo: repo}
|
||||
|
||||
err := svc.DeleteProxy(context.Background(), 404)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{404}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteProxy_Error(t *testing.T) {
|
||||
deleteErr := errors.New("delete failed")
|
||||
repo := &proxyRepoStub{deleteErr: deleteErr}
|
||||
svc := &adminServiceImpl{proxyRepo: repo}
|
||||
|
||||
err := svc.DeleteProxy(context.Background(), 33)
|
||||
require.ErrorIs(t, err, deleteErr)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteRedeemCode_Success(t *testing.T) {
|
||||
repo := &redeemRepoStub{}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
err := svc.DeleteRedeemCode(context.Background(), 10)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{10}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteRedeemCode_Idempotent(t *testing.T) {
|
||||
repo := &redeemRepoStub{}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
err := svc.DeleteRedeemCode(context.Background(), 999)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{999}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteRedeemCode_Error(t *testing.T) {
|
||||
deleteErr := errors.New("delete failed")
|
||||
repo := &redeemRepoStub{deleteErrByID: map[int64]error{1: deleteErr}}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
err := svc.DeleteRedeemCode(context.Background(), 1)
|
||||
require.ErrorIs(t, err, deleteErr)
|
||||
require.Equal(t, []int64{1}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_BatchDeleteRedeemCodes_Success(t *testing.T) {
|
||||
repo := &redeemRepoStub{}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
deleted, err := svc.BatchDeleteRedeemCodes(context.Background(), []int64{1, 2, 3})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), deleted)
|
||||
require.Equal(t, []int64{1, 2, 3}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_BatchDeleteRedeemCodes_PartialFailures(t *testing.T) {
|
||||
repo := &redeemRepoStub{
|
||||
deleteErrByID: map[int64]error{
|
||||
2: errors.New("db error"),
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
deleted, err := svc.BatchDeleteRedeemCodes(context.Background(), []int64{1, 2, 3})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), deleted)
|
||||
require.Equal(t, []int64{1, 2, 3}, repo.deletedIDs)
|
||||
}
|
||||
13
openspec/changes/add-delete-unit-tests/proposal.md
Normal file
13
openspec/changes/add-delete-unit-tests/proposal.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Change: Add unit tests for delete paths (user/group/proxy/redeem)
|
||||
|
||||
## Why
|
||||
删除流程缺少单元测试,容易在重构或边界条件变化时回归,且问题排查成本高。
|
||||
|
||||
## What Changes
|
||||
- 新增服务层删除流程单元测试(覆盖 AdminService 删除入口与对应 Repo 错误传播)
|
||||
- 覆盖成功/不存在/权限保护/幂等删除/底层错误等关键分支
|
||||
- 需要的轻量测试替身(repositories / cache)
|
||||
|
||||
## Impact
|
||||
- Affected specs: testing (new)
|
||||
- Affected code: backend/internal/service/admin_service.go
|
||||
63
openspec/changes/add-delete-unit-tests/specs/testing/spec.md
Normal file
63
openspec/changes/add-delete-unit-tests/specs/testing/spec.md
Normal file
@@ -0,0 +1,63 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Delete path unit coverage
|
||||
服务层删除流程 SHALL 具备单元测试覆盖用户、分组、代理、兑换码等资源的关键分支,且覆盖 AdminService 删除入口的权限保护、幂等删除与错误传播。
|
||||
|
||||
#### Scenario: User delete success
|
||||
- **WHEN** 删除存在的用户
|
||||
- **THEN** 返回成功且仓储删除被调用
|
||||
|
||||
#### Scenario: User delete not found
|
||||
- **WHEN** 删除不存在的用户
|
||||
- **THEN** 返回未找到错误
|
||||
|
||||
#### Scenario: User delete propagates errors
|
||||
- **WHEN** 删除用户时仓储返回错误
|
||||
- **THEN** 错误被向上返回且不吞掉
|
||||
|
||||
#### Scenario: User delete rejects admin accounts
|
||||
- **WHEN** 删除管理员用户
|
||||
- **THEN** 返回拒绝删除的错误
|
||||
|
||||
#### Scenario: Group delete success
|
||||
- **WHEN** 删除存在的分组
|
||||
- **THEN** 返回成功且仓储级联删除被调用
|
||||
|
||||
#### Scenario: Group delete not found
|
||||
- **WHEN** 删除不存在的分组
|
||||
- **THEN** 返回 ErrGroupNotFound
|
||||
|
||||
#### Scenario: Group delete propagates errors
|
||||
- **WHEN** 删除分组时仓储返回错误
|
||||
- **THEN** 错误被向上返回且不吞掉
|
||||
|
||||
#### Scenario: Proxy delete success
|
||||
- **WHEN** 删除存在的代理
|
||||
- **THEN** 返回成功且仓储删除被调用
|
||||
|
||||
#### Scenario: Proxy delete is idempotent
|
||||
- **WHEN** 删除不存在的代理
|
||||
- **THEN** 不返回错误且调用删除流程
|
||||
|
||||
#### Scenario: Proxy delete propagates errors
|
||||
- **WHEN** 删除代理时仓储返回错误
|
||||
- **THEN** 错误被向上返回且不吞掉
|
||||
|
||||
#### Scenario: Redeem code delete success
|
||||
- **WHEN** 删除存在的兑换码
|
||||
- **THEN** 返回成功且仓储删除被调用
|
||||
|
||||
#### Scenario: Redeem code delete is idempotent
|
||||
- **WHEN** 删除不存在的兑换码
|
||||
- **THEN** 不返回错误且调用删除流程
|
||||
|
||||
#### Scenario: Redeem code delete propagates errors
|
||||
- **WHEN** 删除兑换码时仓储返回错误
|
||||
- **THEN** 错误被向上返回且不吞掉
|
||||
|
||||
#### Scenario: Batch redeem code delete success
|
||||
- **WHEN** 批量删除兑换码且全部成功
|
||||
- **THEN** 返回删除数量等于输入数量且不返回错误
|
||||
|
||||
#### Scenario: Batch redeem code delete partial failures
|
||||
- **WHEN** 批量删除兑换码且部分失败
|
||||
- **THEN** 返回删除数量小于输入数量且不返回错误
|
||||
11
openspec/changes/add-delete-unit-tests/tasks.md
Normal file
11
openspec/changes/add-delete-unit-tests/tasks.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## 1. Implementation
|
||||
- [x] 1.1 为 AdminService 删除入口准备测试替身(user/group/proxy/redeem repo 与 cache)
|
||||
- [x] 1.2 新增 AdminService.DeleteUser 单元测试(成功/不存在/错误传播/管理员保护)
|
||||
- [x] 1.3 新增 AdminService.DeleteGroup 单元测试(成功/不存在/错误传播,缓存失效逻辑如适用)
|
||||
- [x] 1.4 新增 AdminService.DeleteProxy 单元测试(成功/幂等删除/错误传播)
|
||||
- [x] 1.5 新增 AdminService.DeleteRedeemCode 与 BatchDeleteRedeemCodes 单元测试(成功/幂等删除/错误传播/部分失败)
|
||||
- [x] 1.6 运行 unit 测试并将结果记录在本 tasks.md 末尾
|
||||
|
||||
## Test Results
|
||||
- `go test -tags=unit ./internal/service/...` (workdir: `backend`)
|
||||
- ok github.com/Wei-Shaw/sub2api/internal/service 0.475s
|
||||
Reference in New Issue
Block a user