api_key_service_touch_last_used_test.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func TestAPIKeyService_TouchLastUsed_InvalidKeyID(t *testing.T) {
  12. repo := &apiKeyRepoStub{
  13. updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
  14. return errors.New("should not be called")
  15. },
  16. }
  17. svc := &APIKeyService{apiKeyRepo: repo}
  18. require.NoError(t, svc.TouchLastUsed(context.Background(), 0))
  19. require.NoError(t, svc.TouchLastUsed(context.Background(), -1))
  20. require.Empty(t, repo.touchedIDs)
  21. }
  22. func TestAPIKeyService_TouchLastUsed_FirstTouchSucceeds(t *testing.T) {
  23. repo := &apiKeyRepoStub{}
  24. svc := &APIKeyService{apiKeyRepo: repo}
  25. err := svc.TouchLastUsed(context.Background(), 123)
  26. require.NoError(t, err)
  27. require.Equal(t, []int64{123}, repo.touchedIDs)
  28. require.Len(t, repo.touchedUsedAts, 1)
  29. require.False(t, repo.touchedUsedAts[0].IsZero())
  30. cached, ok := svc.lastUsedTouchL1.Load(int64(123))
  31. require.True(t, ok, "successful touch should update debounce cache")
  32. _, isTime := cached.(time.Time)
  33. require.True(t, isTime)
  34. }
  35. func TestAPIKeyService_TouchLastUsed_DebouncedWithinWindow(t *testing.T) {
  36. repo := &apiKeyRepoStub{}
  37. svc := &APIKeyService{apiKeyRepo: repo}
  38. require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
  39. require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
  40. require.Equal(t, []int64{123}, repo.touchedIDs, "second touch within debounce window should not hit repository")
  41. }
  42. func TestAPIKeyService_TouchLastUsed_ExpiredDebounceTouchesAgain(t *testing.T) {
  43. repo := &apiKeyRepoStub{}
  44. svc := &APIKeyService{apiKeyRepo: repo}
  45. require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
  46. // 强制将 debounce 时间回拨到窗口之外,触发第二次写库。
  47. svc.lastUsedTouchL1.Store(int64(123), time.Now().Add(-apiKeyLastUsedMinTouch-time.Second))
  48. require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
  49. require.Len(t, repo.touchedIDs, 2)
  50. require.Equal(t, int64(123), repo.touchedIDs[0])
  51. require.Equal(t, int64(123), repo.touchedIDs[1])
  52. }
  53. func TestAPIKeyService_TouchLastUsed_RepoError(t *testing.T) {
  54. repo := &apiKeyRepoStub{
  55. updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
  56. return errors.New("db write failed")
  57. },
  58. }
  59. svc := &APIKeyService{apiKeyRepo: repo}
  60. err := svc.TouchLastUsed(context.Background(), 123)
  61. require.Error(t, err)
  62. require.ErrorContains(t, err, "touch api key last used")
  63. require.Equal(t, []int64{123}, repo.touchedIDs)
  64. cached, ok := svc.lastUsedTouchL1.Load(int64(123))
  65. require.True(t, ok, "failed touch should still update retry debounce cache")
  66. _, isTime := cached.(time.Time)
  67. require.True(t, isTime)
  68. }
  69. func TestAPIKeyService_TouchLastUsed_RepoErrorDebounced(t *testing.T) {
  70. repo := &apiKeyRepoStub{
  71. updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
  72. return errors.New("db write failed")
  73. },
  74. }
  75. svc := &APIKeyService{apiKeyRepo: repo}
  76. firstErr := svc.TouchLastUsed(context.Background(), 456)
  77. require.Error(t, firstErr)
  78. require.ErrorContains(t, firstErr, "touch api key last used")
  79. secondErr := svc.TouchLastUsed(context.Background(), 456)
  80. require.NoError(t, secondErr, "failed touch should be debounced and skip immediate retry")
  81. require.Equal(t, []int64{456}, repo.touchedIDs, "debounced retry should not hit repository again")
  82. }
  83. type touchSingleflightRepo struct {
  84. *apiKeyRepoStub
  85. mu sync.Mutex
  86. calls int
  87. blockCh chan struct{}
  88. }
  89. func (r *touchSingleflightRepo) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
  90. r.mu.Lock()
  91. r.calls++
  92. r.mu.Unlock()
  93. <-r.blockCh
  94. return nil
  95. }
  96. func TestAPIKeyService_TouchLastUsed_ConcurrentFirstTouchDeduplicated(t *testing.T) {
  97. repo := &touchSingleflightRepo{
  98. apiKeyRepoStub: &apiKeyRepoStub{},
  99. blockCh: make(chan struct{}),
  100. }
  101. svc := &APIKeyService{apiKeyRepo: repo}
  102. const workers = 20
  103. startCh := make(chan struct{})
  104. errCh := make(chan error, workers)
  105. var wg sync.WaitGroup
  106. for i := 0; i < workers; i++ {
  107. wg.Add(1)
  108. go func() {
  109. defer wg.Done()
  110. <-startCh
  111. errCh <- svc.TouchLastUsed(context.Background(), 321)
  112. }()
  113. }
  114. close(startCh)
  115. require.Eventually(t, func() bool {
  116. repo.mu.Lock()
  117. defer repo.mu.Unlock()
  118. return repo.calls >= 1
  119. }, time.Second, 10*time.Millisecond)
  120. close(repo.blockCh)
  121. wg.Wait()
  122. close(errCh)
  123. for err := range errCh {
  124. require.NoError(t, err)
  125. }
  126. repo.mu.Lock()
  127. defer repo.mu.Unlock()
  128. require.Equal(t, 1, repo.calls, "并发首次 touch 只应写库一次")
  129. }