| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "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
- rangeStats *usagestats.DashboardStats
- err error
- rangeErr error
- calls int32
- rangeCalls int32
- rangeStart time.Time
- rangeEnd time.Time
- 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
- }
- func (s *usageRepoStub) GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*usagestats.DashboardStats, error) {
- atomic.AddInt32(&s.rangeCalls, 1)
- s.rangeStart = start
- s.rangeEnd = end
- if s.rangeErr != nil {
- return nil, s.rangeErr
- }
- if s.rangeStats != nil {
- return s.rangeStats, nil
- }
- 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
- del func(ctx context.Context) error
- getCalls int32
- setCalls int32
- delCalls 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) DeleteDashboardStats(ctx context.Context) error {
- atomic.AddInt32(&c.delCalls, 1)
- if c.del != nil {
- return c.del(ctx)
- }
- return nil
- }
- type dashboardAggregationRepoStub struct {
- watermark time.Time
- err error
- }
- func (s *dashboardAggregationRepoStub) AggregateRange(ctx context.Context, start, end time.Time) error {
- return nil
- }
- func (s *dashboardAggregationRepoStub) RecomputeRange(ctx context.Context, start, end time.Time) error {
- return nil
- }
- func (s *dashboardAggregationRepoStub) GetAggregationWatermark(ctx context.Context) (time.Time, error) {
- if s.err != nil {
- return time.Time{}, s.err
- }
- return s.watermark, nil
- }
- func (s *dashboardAggregationRepoStub) UpdateAggregationWatermark(ctx context.Context, aggregatedAt time.Time) error {
- return nil
- }
- func (s *dashboardAggregationRepoStub) CleanupAggregates(ctx context.Context, hourlyCutoff, dailyCutoff time.Time) error {
- return nil
- }
- func (s *dashboardAggregationRepoStub) CleanupUsageLogs(ctx context.Context, cutoff time.Time) error {
- return nil
- }
- func (s *dashboardAggregationRepoStub) CleanupUsageBillingDedup(ctx context.Context, cutoff time.Time) error {
- return nil
- }
- func (s *dashboardAggregationRepoStub) EnsureUsageLogsPartitions(ctx context.Context, now time.Time) error {
- 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,
- StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
- StatsStale: true,
- }
- 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},
- }
- aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: true},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: true,
- },
- }
- svc := NewDashboardService(repo, aggRepo, 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,
- StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
- StatsStale: true,
- }
- cache := &dashboardCacheStub{
- get: func(ctx context.Context) (string, error) {
- return "", ErrDashboardStatsCacheMiss
- },
- }
- repo := &usageRepoStub{stats: stats}
- aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: true},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: true,
- },
- }
- svc := NewDashboardService(repo, aggRepo, 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,
- StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
- StatsStale: true,
- }
- cache := &dashboardCacheStub{
- get: func(ctx context.Context) (string, error) {
- return "", nil
- },
- }
- repo := &usageRepoStub{stats: stats}
- aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: false},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: true,
- },
- }
- svc := NewDashboardService(repo, aggRepo, 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,
- StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
- StatsStale: true,
- }
- 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,
- }
- aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: true},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: true,
- },
- }
- svc := NewDashboardService(repo, aggRepo, 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)
- }
- func TestDashboardService_CacheParseError_EvictsAndRefetches(t *testing.T) {
- cache := &dashboardCacheStub{
- get: func(ctx context.Context) (string, error) {
- return "not-json", nil
- },
- }
- stats := &usagestats.DashboardStats{TotalUsers: 9}
- repo := &usageRepoStub{stats: stats}
- aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: true},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: true,
- },
- }
- svc := NewDashboardService(repo, aggRepo, cache, cfg)
- got, err := svc.GetDashboardStats(context.Background())
- require.NoError(t, err)
- require.Equal(t, stats, got)
- require.Equal(t, int32(1), atomic.LoadInt32(&cache.delCalls))
- require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls))
- }
- func TestDashboardService_CacheParseError_RepoFailure(t *testing.T) {
- cache := &dashboardCacheStub{
- get: func(ctx context.Context) (string, error) {
- return "not-json", nil
- },
- }
- repo := &usageRepoStub{err: errors.New("db down")}
- aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: true},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: true,
- },
- }
- svc := NewDashboardService(repo, aggRepo, cache, cfg)
- _, err := svc.GetDashboardStats(context.Background())
- require.Error(t, err)
- require.Equal(t, int32(1), atomic.LoadInt32(&cache.delCalls))
- }
- func TestDashboardService_StatsUpdatedAtEpochWhenMissing(t *testing.T) {
- stats := &usagestats.DashboardStats{}
- repo := &usageRepoStub{stats: stats}
- aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
- cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: false}}
- svc := NewDashboardService(repo, aggRepo, nil, cfg)
- got, err := svc.GetDashboardStats(context.Background())
- require.NoError(t, err)
- require.Equal(t, "1970-01-01T00:00:00Z", got.StatsUpdatedAt)
- require.True(t, got.StatsStale)
- }
- func TestDashboardService_StatsStaleFalseWhenFresh(t *testing.T) {
- aggNow := time.Now().UTC().Truncate(time.Second)
- stats := &usagestats.DashboardStats{}
- repo := &usageRepoStub{stats: stats}
- aggRepo := &dashboardAggregationRepoStub{watermark: aggNow}
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: false},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: true,
- IntervalSeconds: 60,
- LookbackSeconds: 120,
- },
- }
- svc := NewDashboardService(repo, aggRepo, nil, cfg)
- got, err := svc.GetDashboardStats(context.Background())
- require.NoError(t, err)
- require.Equal(t, aggNow.Format(time.RFC3339), got.StatsUpdatedAt)
- require.False(t, got.StatsStale)
- }
- func TestDashboardService_AggDisabled_UsesUsageLogsFallback(t *testing.T) {
- expected := &usagestats.DashboardStats{TotalUsers: 42}
- repo := &usageRepoStub{
- rangeStats: expected,
- err: errors.New("should not call aggregated stats"),
- }
- cfg := &config.Config{
- Dashboard: config.DashboardCacheConfig{Enabled: false},
- DashboardAgg: config.DashboardAggregationConfig{
- Enabled: false,
- Retention: config.DashboardAggregationRetentionConfig{
- UsageLogsDays: 7,
- },
- },
- }
- svc := NewDashboardService(repo, nil, nil, cfg)
- got, err := svc.GetDashboardStats(context.Background())
- require.NoError(t, err)
- require.Equal(t, int64(42), got.TotalUsers)
- require.Equal(t, int32(0), atomic.LoadInt32(&repo.calls))
- require.Equal(t, int32(1), atomic.LoadInt32(&repo.rangeCalls))
- require.False(t, repo.rangeEnd.IsZero())
- require.Equal(t, truncateToDayUTC(repo.rangeEnd.AddDate(0, 0, -7)), repo.rangeStart)
- }
|