Files
sub2api/backend/internal/service/api_key_service_delete_test.go
T
shaw 86fb4781f4 refactor(repository): scope user/api-key updates to declared columns
UserRepository.Update and APIKeyRepository.Update rewrote the whole row on
every call, regardless of which fields the caller meant to change. Several
columns on those tables are maintained by dedicated atomic paths (balance
deduction, quota and rate-limit counters, limit adjustments, activity
timestamps), so a caller holding a slightly older snapshot could silently
roll them back - a lost update.

Both methods now take an explicit column mask and persist only the columns
the caller declares; everything else keeps its current database value.

- All user and API-key call sites declare exactly what they mutate, which
  turns admin edits and profile saves into genuine partial updates.
- Email uniqueness locking/lookup and allowed_groups sync only run when
  those fields are part of the update.
- UserUpdateFields deliberately has no balance/total_recharged members, so
  Update cannot touch them. New AdjustBalance/SetBalance apply the change in
  a single statement and return before/after values; admin balance
  adjustment uses them instead of read-modify-write.
- promo_codes.used_count is no longer written by Update; it is only ever
  incremented by the redemption path.
- The billing hot path that marks an API key quota-exhausted writes only
  status.
- Dropped a no-op row write in RevokeAllUserTokens: users has no
  token_version column, so it persisted nothing while still overwriting
  concurrently-updated columns.

Adds integration coverage that a stale snapshot cannot revert concurrent
atomic writes, and unit coverage pinning the column set each entry point
declares.
2026-07-28 17:21:32 +08:00

502 lines
18 KiB
Go

//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 缓存未被清除
}