package service import ( "context" "encoding/json" "sync" "sync/atomic" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/stretchr/testify/require" ) type usageRepoStub struct { UsageLogRepository stats *usagestats.DashboardStats err error calls int32 onCall chan struct{} } func (s *usageRepoStub) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) { atomic.AddInt32(&s.calls, 1) if s.onCall != nil { select { case s.onCall <- struct{}{}: default: } } if s.err != nil { return nil, s.err } return s.stats, nil } type dashboardCacheStub struct { get func(ctx context.Context) (string, error) set func(ctx context.Context, data string, ttl time.Duration) error getCalls int32 setCalls int32 lastSetMu sync.Mutex lastSet string } func (c *dashboardCacheStub) GetDashboardStats(ctx context.Context) (string, error) { atomic.AddInt32(&c.getCalls, 1) if c.get != nil { return c.get(ctx) } return "", ErrDashboardStatsCacheMiss } func (c *dashboardCacheStub) SetDashboardStats(ctx context.Context, data string, ttl time.Duration) error { atomic.AddInt32(&c.setCalls, 1) c.lastSetMu.Lock() c.lastSet = data c.lastSetMu.Unlock() if c.set != nil { return c.set(ctx, data, ttl) } return nil } func (c *dashboardCacheStub) readLastEntry(t *testing.T) dashboardStatsCacheEntry { t.Helper() c.lastSetMu.Lock() data := c.lastSet c.lastSetMu.Unlock() var entry dashboardStatsCacheEntry err := json.Unmarshal([]byte(data), &entry) require.NoError(t, err) return entry } func TestDashboardService_CacheHitFresh(t *testing.T) { stats := &usagestats.DashboardStats{ TotalUsers: 10, } entry := dashboardStatsCacheEntry{ Stats: stats, UpdatedAt: time.Now().Unix(), } payload, err := json.Marshal(entry) require.NoError(t, err) cache := &dashboardCacheStub{ get: func(ctx context.Context) (string, error) { return string(payload), nil }, } repo := &usageRepoStub{ stats: &usagestats.DashboardStats{TotalUsers: 99}, } cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: true}} svc := NewDashboardService(repo, cache, cfg) got, err := svc.GetDashboardStats(context.Background()) require.NoError(t, err) require.Equal(t, stats, got) require.Equal(t, int32(0), atomic.LoadInt32(&repo.calls)) require.Equal(t, int32(1), atomic.LoadInt32(&cache.getCalls)) require.Equal(t, int32(0), atomic.LoadInt32(&cache.setCalls)) } func TestDashboardService_CacheMiss_StoresCache(t *testing.T) { stats := &usagestats.DashboardStats{ TotalUsers: 7, } cache := &dashboardCacheStub{ get: func(ctx context.Context) (string, error) { return "", ErrDashboardStatsCacheMiss }, } repo := &usageRepoStub{stats: stats} cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: true}} svc := NewDashboardService(repo, cache, cfg) got, err := svc.GetDashboardStats(context.Background()) require.NoError(t, err) require.Equal(t, stats, got) require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls)) require.Equal(t, int32(1), atomic.LoadInt32(&cache.getCalls)) require.Equal(t, int32(1), atomic.LoadInt32(&cache.setCalls)) entry := cache.readLastEntry(t) require.Equal(t, stats, entry.Stats) require.WithinDuration(t, time.Now(), time.Unix(entry.UpdatedAt, 0), time.Second) } func TestDashboardService_CacheDisabled_SkipsCache(t *testing.T) { stats := &usagestats.DashboardStats{ TotalUsers: 3, } cache := &dashboardCacheStub{ get: func(ctx context.Context) (string, error) { return "", nil }, } repo := &usageRepoStub{stats: stats} cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: false}} svc := NewDashboardService(repo, cache, cfg) got, err := svc.GetDashboardStats(context.Background()) require.NoError(t, err) require.Equal(t, stats, got) require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls)) require.Equal(t, int32(0), atomic.LoadInt32(&cache.getCalls)) require.Equal(t, int32(0), atomic.LoadInt32(&cache.setCalls)) } func TestDashboardService_CacheHitStale_TriggersAsyncRefresh(t *testing.T) { staleStats := &usagestats.DashboardStats{ TotalUsers: 11, } entry := dashboardStatsCacheEntry{ Stats: staleStats, UpdatedAt: time.Now().Add(-defaultDashboardStatsFreshTTL * 2).Unix(), } payload, err := json.Marshal(entry) require.NoError(t, err) cache := &dashboardCacheStub{ get: func(ctx context.Context) (string, error) { return string(payload), nil }, } refreshCh := make(chan struct{}, 1) repo := &usageRepoStub{ stats: &usagestats.DashboardStats{TotalUsers: 22}, onCall: refreshCh, } cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: true}} svc := NewDashboardService(repo, cache, cfg) got, err := svc.GetDashboardStats(context.Background()) require.NoError(t, err) require.Equal(t, staleStats, got) select { case <-refreshCh: case <-time.After(1 * time.Second): t.Fatal("等待异步刷新超时") } require.Eventually(t, func() bool { return atomic.LoadInt32(&cache.setCalls) >= 1 }, 1*time.Second, 10*time.Millisecond) }