314 lines
9.4 KiB
Go
314 lines
9.4 KiB
Go
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
|
|
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
|
|
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) 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) 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}}
|
|
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}}
|
|
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}}
|
|
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}}
|
|
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}}
|
|
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}}
|
|
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)
|
|
}
|