//go:build unit // API Key 服务删除方法的单元测试 // 测试 APIKeyService.Delete 方法在各种场景下的行为, // 包括权限验证、缓存清理和错误处理 package service import ( "context" "errors" "strings" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" ) // apiKeyRepoStub 是 APIKeyRepository 接口的测试桩实现。 // 用于隔离测试 APIKeyService.Delete 方法,避免依赖真实数据库。 // // 设计说明: // - apiKey/getByIDErr: 模拟 GetKeyAndOwnerID 返回的记录与错误 // - deleteErr: 模拟 Delete 返回的错误 // - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证 type apiKeyRepoStub struct { apiKey *APIKey // GetKeyAndOwnerID 的返回值 getByIDErr error // GetKeyAndOwnerID 的错误返回值 deleteErr error // Delete 的错误返回值 updateErr error // Update 的错误返回值 deletedIDs []int64 // 记录已删除的 API Key ID 列表 updatedKeys []APIKey allowListByUserID bool listByUserIDKeys []APIKey listByUserIDErr error listByUserIDCalls []int64 listByUserIDParams []pagination.PaginationParams listByUserIDFilters []APIKeyListFilters allowListAllByUserID bool listAllByUserIDKeys []APIKey listAllByUserIDErr error listAllByUserIDCalls []int64 listAllByUserIDFilters []APIKeyListFilters updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error touchedIDs []int64 touchedUsedAts []time.Time } // 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题 func (s *apiKeyRepoStub) Create(ctx context.Context, key *APIKey) error { panic("unexpected Create call") } func (s *apiKeyRepoStub) GetByID(ctx context.Context, id int64) (*APIKey, error) { if s.getByIDErr != nil { return nil, s.getByIDErr } if s.apiKey != nil { clone := *s.apiKey return &clone, nil } panic("unexpected GetByID call") } func (s *apiKeyRepoStub) GetKeyAndOwnerID(ctx context.Context, id int64) (string, int64, error) { if s.getByIDErr != nil { return "", 0, s.getByIDErr } if s.apiKey != nil { return s.apiKey.Key, s.apiKey.UserID, nil } return "", 0, ErrAPIKeyNotFound } func (s *apiKeyRepoStub) GetByKey(ctx context.Context, key string) (*APIKey, error) { panic("unexpected GetByKey call") } func (s *apiKeyRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*APIKey, error) { panic("unexpected GetByKeyForAuth call") } func (s *apiKeyRepoStub) Update(ctx context.Context, key *APIKey, _ APIKeyUpdateFields) error { if key != nil { s.updatedKeys = append(s.updatedKeys, *key) } return s.updateErr } // Delete 记录被删除的 API Key ID 并返回预设的错误。 // 通过 deletedIDs 可以验证删除操作是否被正确调用。 func (s *apiKeyRepoStub) Delete(ctx context.Context, id int64) error { s.deletedIDs = append(s.deletedIDs, id) return s.deleteErr } // DeleteWithAudit 与 Delete 一样记录被删除的 ID,供 service 测试断言。 func (s *apiKeyRepoStub) DeleteWithAudit(ctx context.Context, id int64) error { s.deletedIDs = append(s.deletedIDs, id) return s.deleteErr } // 以下是接口要求实现但本测试不关心的方法 func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { if !s.allowListByUserID { panic("unexpected ListByUserID call") } s.listByUserIDCalls = append(s.listByUserIDCalls, userID) s.listByUserIDParams = append(s.listByUserIDParams, params) s.listByUserIDFilters = append(s.listByUserIDFilters, filters) if s.listByUserIDErr != nil { return nil, nil, s.listByUserIDErr } keys := append([]APIKey(nil), s.listByUserIDKeys...) return keys, &pagination.PaginationResult{ Total: int64(len(keys)), Page: params.Page, PageSize: params.PageSize, Pages: 1, }, nil } func (s *apiKeyRepoStub) ListAllByUserID(ctx context.Context, userID int64, filters APIKeyListFilters) ([]APIKey, error) { if !s.allowListAllByUserID { panic("unexpected ListAllByUserID call") } s.listAllByUserIDCalls = append(s.listAllByUserIDCalls, userID) s.listAllByUserIDFilters = append(s.listAllByUserIDFilters, filters) if s.listAllByUserIDErr != nil { return nil, s.listAllByUserIDErr } source := s.listByUserIDKeys if s.listAllByUserIDKeys != nil { source = s.listAllByUserIDKeys } return filterAPIKeyStubKeys(userID, source, filters), nil } func filterAPIKeyStubKeys(userID int64, keys []APIKey, filters APIKeyListFilters) []APIKey { result := make([]APIKey, 0, len(keys)) search := strings.ToLower(filters.Search) for _, key := range keys { if key.UserID != userID { continue } if search != "" && !strings.Contains(strings.ToLower(key.Name), search) && !strings.Contains(strings.ToLower(key.Key), search) { continue } if filters.Status != "" && key.Status != filters.Status { continue } if filters.GroupID != nil { if *filters.GroupID == 0 { if key.GroupID != nil { continue } } else if key.GroupID == nil || *key.GroupID != *filters.GroupID { continue } } result = append(result, key) } return result } func (s *apiKeyRepoStub) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { panic("unexpected VerifyOwnership call") } func (s *apiKeyRepoStub) CountByUserID(ctx context.Context, userID int64) (int64, error) { panic("unexpected CountByUserID call") } func (s *apiKeyRepoStub) ExistsByKey(ctx context.Context, key string) (bool, error) { panic("unexpected ExistsByKey call") } func (s *apiKeyRepoStub) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) { panic("unexpected ListByGroupID call") } func (s *apiKeyRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error) { panic("unexpected SearchAPIKeys call") } func (s *apiKeyRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected ClearGroupIDByGroupID call") } func (s *apiKeyRepoStub) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { panic("unexpected UpdateGroupIDByUserAndGroup call") } func (s *apiKeyRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected CountByGroupID call") } func (s *apiKeyRepoStub) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) { panic("unexpected ListKeysByUserID call") } func (s *apiKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) { panic("unexpected ListKeysByGroupID call") } func (s *apiKeyRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) { panic("unexpected IncrementQuotaUsed call") } func (s *apiKeyRepoStub) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error { s.touchedIDs = append(s.touchedIDs, id) s.touchedUsedAts = append(s.touchedUsedAts, usedAt) if s.updateLastUsed != nil { return s.updateLastUsed(ctx, id, usedAt) } return nil } func (s *apiKeyRepoStub) IncrementRateLimitUsage(ctx context.Context, id int64, cost float64) error { panic("unexpected IncrementRateLimitUsage call") } func (s *apiKeyRepoStub) ResetRateLimitWindows(ctx context.Context, id int64) error { panic("unexpected ResetRateLimitWindows call") } func (s *apiKeyRepoStub) GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error) { panic("unexpected GetRateLimitData call") } // apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。 // 用于验证删除操作时缓存清理逻辑是否被正确调用。 // // 设计说明: // - invalidated: 记录被清除缓存的用户 ID 列表 type apiKeyCacheStub struct { invalidated []int64 // 记录调用 DeleteCreateAttemptCount 时传入的用户 ID deleteAuthKeys []string // 记录调用 DeleteAuthCache 时传入的缓存 key } // GetCreateAttemptCount 返回 0,表示用户未超过创建次数限制 func (s *apiKeyCacheStub) GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) { return 0, nil } // IncrementCreateAttemptCount 空实现,本测试不验证此行为 func (s *apiKeyCacheStub) IncrementCreateAttemptCount(ctx context.Context, userID int64) error { return nil } // DeleteCreateAttemptCount 记录被清除缓存的用户 ID。 // 删除 API Key 时会调用此方法清除用户的创建尝试计数缓存。 func (s *apiKeyCacheStub) DeleteCreateAttemptCount(ctx context.Context, userID int64) error { s.invalidated = append(s.invalidated, userID) return nil } // IncrementDailyUsage 空实现,本测试不验证此行为 func (s *apiKeyCacheStub) IncrementDailyUsage(ctx context.Context, apiKey string) error { return nil } // SetDailyUsageExpiry 空实现,本测试不验证此行为 func (s *apiKeyCacheStub) SetDailyUsageExpiry(ctx context.Context, apiKey string, ttl time.Duration) error { return nil } func (s *apiKeyCacheStub) GetAuthCache(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) { return nil, nil } func (s *apiKeyCacheStub) SetAuthCache(ctx context.Context, key string, entry *APIKeyAuthCacheEntry, ttl time.Duration) error { return nil } func (s *apiKeyCacheStub) DeleteAuthCache(ctx context.Context, key string) error { s.deleteAuthKeys = append(s.deleteAuthKeys, key) return nil } func (s *apiKeyCacheStub) PublishAuthCacheInvalidation(ctx context.Context, cacheKey string) error { return nil } func (s *apiKeyCacheStub) SubscribeAuthCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error { return nil } // TestApiKeyService_Delete_OwnerMismatch 测试非所有者尝试删除时返回权限错误。 // 预期行为: // - GetKeyAndOwnerID 返回所有者 ID 为 1 // - 调用者 userID 为 2(不匹配) // - 返回 ErrInsufficientPerms 错误 // - Delete 方法不被调用 // - 缓存不被清除 func TestApiKeyService_Delete_OwnerMismatch(t *testing.T) { repo := &apiKeyRepoStub{ apiKey: &APIKey{ID: 10, UserID: 1, Key: "k"}, } cache := &apiKeyCacheStub{} svc := &APIKeyService{apiKeyRepo: repo, cache: cache} err := svc.Delete(context.Background(), 10, 2) // API Key ID=10, 调用者 userID=2 require.ErrorIs(t, err, ErrInsufficientPerms) require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用 require.Empty(t, cache.invalidated) // 验证缓存未被清除 require.Empty(t, cache.deleteAuthKeys) } // TestApiKeyService_Delete_Success 测试所有者成功删除 API Key 的场景。 // 预期行为: // - GetKeyAndOwnerID 返回所有者 ID 为 7 // - 调用者 userID 为 7(匹配) // - Delete 成功执行 // - 缓存被正确清除(使用 ownerID) // - 返回 nil 错误 func TestApiKeyService_Delete_Success(t *testing.T) { repo := &apiKeyRepoStub{ apiKey: &APIKey{ID: 42, UserID: 7, Key: "k"}, } cache := &apiKeyCacheStub{} svc := &APIKeyService{apiKeyRepo: repo, cache: cache} svc.lastUsedTouchL1.Store(int64(42), time.Now()) err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7 require.NoError(t, err) require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除 require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除 require.Equal(t, []string{svc.authCacheKey("k")}, cache.deleteAuthKeys) _, exists := svc.lastUsedTouchL1.Load(int64(42)) require.False(t, exists, "delete should clear touch debounce cache") } // TestApiKeyService_Delete_NotFound 测试删除不存在的 API Key 时返回正确的错误。 // 预期行为: // - GetKeyAndOwnerID 返回 ErrAPIKeyNotFound 错误 // - 返回 ErrAPIKeyNotFound 错误(被 fmt.Errorf 包装) // - Delete 方法不被调用 // - 缓存不被清除 func TestApiKeyService_Delete_NotFound(t *testing.T) { repo := &apiKeyRepoStub{getByIDErr: ErrAPIKeyNotFound} cache := &apiKeyCacheStub{} svc := &APIKeyService{apiKeyRepo: repo, cache: cache} err := svc.Delete(context.Background(), 99, 1) require.ErrorIs(t, err, ErrAPIKeyNotFound) require.Empty(t, repo.deletedIDs) require.Empty(t, cache.invalidated) require.Empty(t, cache.deleteAuthKeys) } func TestAPIKeyService_List_FillsCurrentConcurrency(t *testing.T) { repo := &apiKeyRepoStub{ allowListByUserID: true, listByUserIDKeys: []APIKey{ {ID: 10, UserID: 7, Key: "sk-10", Name: "key-10"}, {ID: 11, UserID: 7, Key: "sk-11", Name: "key-11"}, }, } concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{ apiKeyConcurrency: map[int64]int{10: 2, 11: 0}, }) svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency} keys, _, err := svc.List(context.Background(), 7, pagination.PaginationParams{Page: 1, PageSize: 20}, APIKeyListFilters{}) require.NoError(t, err) require.Len(t, keys, 2) require.Equal(t, 2, keys[0].CurrentConcurrency) require.Equal(t, 0, keys[1].CurrentConcurrency) } func TestAPIKeyService_List_SortByCurrentConcurrency(t *testing.T) { groupID := int64(42) keys := []APIKey{ {ID: 1, UserID: 7, Key: "sk-target-1", Name: "target-one", GroupID: &groupID, Status: StatusActive}, {ID: 2, UserID: 7, Key: "sk-target-2", Name: "target-two", GroupID: &groupID, Status: StatusActive}, {ID: 3, UserID: 7, Key: "sk-target-3", Name: "target-three", GroupID: &groupID, Status: StatusActive}, {ID: 4, UserID: 7, Key: "sk-target-4", Name: "target-four", GroupID: &groupID, Status: StatusActive}, {ID: 9, UserID: 7, Key: "sk-target-9", Name: "target-inactive", GroupID: &groupID, Status: StatusDisabled}, {ID: 10, UserID: 7, Key: "sk-other-10", Name: "other", GroupID: &groupID, Status: StatusActive}, {ID: 11, UserID: 7, Key: "sk-target-11", Name: "target-no-group", Status: StatusActive}, {ID: 12, UserID: 8, Key: "sk-target-12", Name: "target-other-user", GroupID: &groupID, Status: StatusActive}, } filters := APIKeyListFilters{ Search: "target", Status: StatusActive, GroupID: &groupID, } repo := &apiKeyRepoStub{ allowListAllByUserID: true, listAllByUserIDKeys: keys, } concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{ apiKeyConcurrency: map[int64]int{ 1: 5, 2: 5, 3: 2, 4: 8, 9: 99, 10: 99, 11: 99, 12: 99, }, }) svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency} got, page, err := svc.List(context.Background(), 7, pagination.PaginationParams{ Page: 2, PageSize: 2, SortBy: "current_concurrency", SortOrder: "desc", }, filters) require.NoError(t, err) require.Equal(t, []int64{1, 3}, apiKeyTestIDs(got)) require.Equal(t, int64(4), page.Total) require.Equal(t, 2, page.Page) require.Equal(t, 2, page.PageSize) require.Equal(t, 2, page.Pages) require.Empty(t, repo.listByUserIDCalls) require.Equal(t, []int64{7}, repo.listAllByUserIDCalls) require.Len(t, repo.listAllByUserIDFilters, 1) require.Equal(t, filters.Search, repo.listAllByUserIDFilters[0].Search) require.Equal(t, filters.Status, repo.listAllByUserIDFilters[0].Status) require.NotNil(t, repo.listAllByUserIDFilters[0].GroupID) require.Equal(t, groupID, *repo.listAllByUserIDFilters[0].GroupID) } func TestAPIKeyService_List_SortByCurrentConcurrencyAscTiesByID(t *testing.T) { repo := &apiKeyRepoStub{ allowListAllByUserID: true, listAllByUserIDKeys: []APIKey{ {ID: 1, UserID: 7, Key: "sk-1", Name: "one", Status: StatusActive}, {ID: 2, UserID: 7, Key: "sk-2", Name: "two", Status: StatusActive}, {ID: 3, UserID: 7, Key: "sk-3", Name: "three", Status: StatusActive}, {ID: 4, UserID: 7, Key: "sk-4", Name: "four", Status: StatusActive}, }, } concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{ apiKeyConcurrency: map[int64]int{1: 5, 2: 5, 3: 2, 4: 8}, }) svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency} got, page, err := svc.List(context.Background(), 7, pagination.PaginationParams{ Page: 1, PageSize: 4, SortBy: "current_concurrency", SortOrder: "asc", }, APIKeyListFilters{}) require.NoError(t, err) require.Equal(t, []int64{3, 1, 2, 4}, apiKeyTestIDs(got)) require.Equal(t, 4, page.PageSize) } func apiKeyTestIDs(keys []APIKey) []int64 { ids := make([]int64, 0, len(keys)) for _, key := range keys { ids = append(ids, key.ID) } return ids } func TestAPIKeyService_GetByID_FillsCurrentConcurrency(t *testing.T) { repo := &apiKeyRepoStub{ apiKey: &APIKey{ID: 10, UserID: 7, Key: "sk-10", Name: "key-10"}, } concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{ apiKeyConcurrency: map[int64]int{10: 4}, }) svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency} key, err := svc.GetByID(context.Background(), 10) require.NoError(t, err) require.Equal(t, 4, key.CurrentConcurrency) } // TestApiKeyService_Delete_DeleteFails 测试删除操作失败时的错误处理。 // 预期行为: // - GetKeyAndOwnerID 返回正确的所有者 ID // - 所有权验证通过 // - DeleteWithAudit 被调用但返回错误 // - 删除失败时缓存不被清除(缓存清理在删除成功后执行,消除竞态) // - 返回包含 "delete api key" 的错误信息 func TestApiKeyService_Delete_DeleteFails(t *testing.T) { repo := &apiKeyRepoStub{ apiKey: &APIKey{ID: 42, UserID: 3, Key: "k"}, deleteErr: errors.New("delete failed"), } cache := &apiKeyCacheStub{} svc := &APIKeyService{apiKeyRepo: repo, cache: cache} err := svc.Delete(context.Background(), 3, 3) // API Key ID=3, 调用者 userID=3 require.Error(t, err) require.ErrorContains(t, err, "delete api key") require.Equal(t, []int64{3}, repo.deletedIDs) // 验证 DeleteWithAudit 被调用 require.Empty(t, cache.invalidated) // 验证删除失败时缓存未被清除(新顺序:先删后清) require.Empty(t, cache.deleteAuthKeys) // 验证删除失败时 auth 缓存未被清除 }