From f6de36cb04a6d68410303951f089a91aae9da064 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Mon, 29 Dec 2025 15:01:19 +0800 Subject: [PATCH] =?UTF-8?q?test(=E5=88=A0=E9=99=A4):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8D=95=E6=B5=8B=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=E4=BB=B6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 AdminService 删除路径单元测试与规范场景更新\n同步调整 Google API Key 中间件测试桩与签名 --- .../middleware/api_key_auth_google_test.go | 33 +- .../service/admin_service_delete_test.go | 448 ++++++++++++++++++ .../changes/add-delete-unit-tests/proposal.md | 13 + .../specs/testing/spec.md | 63 +++ .../changes/add-delete-unit-tests/tasks.md | 11 + 5 files changed, 558 insertions(+), 10 deletions(-) create mode 100644 backend/internal/service/admin_service_delete_test.go create mode 100644 openspec/changes/add-delete-unit-tests/proposal.md create mode 100644 openspec/changes/add-delete-unit-tests/specs/testing/spec.md create mode 100644 openspec/changes/add-delete-unit-tests/tasks.md diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 6f3ade9e..04d67977 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -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) diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go new file mode 100644 index 00000000..80af809b --- /dev/null +++ b/backend/internal/service/admin_service_delete_test.go @@ -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) +} diff --git a/openspec/changes/add-delete-unit-tests/proposal.md b/openspec/changes/add-delete-unit-tests/proposal.md new file mode 100644 index 00000000..a17e0086 --- /dev/null +++ b/openspec/changes/add-delete-unit-tests/proposal.md @@ -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 diff --git a/openspec/changes/add-delete-unit-tests/specs/testing/spec.md b/openspec/changes/add-delete-unit-tests/specs/testing/spec.md new file mode 100644 index 00000000..756fbac8 --- /dev/null +++ b/openspec/changes/add-delete-unit-tests/specs/testing/spec.md @@ -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** 返回删除数量小于输入数量且不返回错误 diff --git a/openspec/changes/add-delete-unit-tests/tasks.md b/openspec/changes/add-delete-unit-tests/tasks.md new file mode 100644 index 00000000..5942ee63 --- /dev/null +++ b/openspec/changes/add-delete-unit-tests/tasks.md @@ -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