dashboard_service_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "sync"
  7. "sync/atomic"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type usageRepoStub struct {
  15. UsageLogRepository
  16. stats *usagestats.DashboardStats
  17. rangeStats *usagestats.DashboardStats
  18. err error
  19. rangeErr error
  20. calls int32
  21. rangeCalls int32
  22. rangeStart time.Time
  23. rangeEnd time.Time
  24. onCall chan struct{}
  25. }
  26. func (s *usageRepoStub) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
  27. atomic.AddInt32(&s.calls, 1)
  28. if s.onCall != nil {
  29. select {
  30. case s.onCall <- struct{}{}:
  31. default:
  32. }
  33. }
  34. if s.err != nil {
  35. return nil, s.err
  36. }
  37. return s.stats, nil
  38. }
  39. func (s *usageRepoStub) GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*usagestats.DashboardStats, error) {
  40. atomic.AddInt32(&s.rangeCalls, 1)
  41. s.rangeStart = start
  42. s.rangeEnd = end
  43. if s.rangeErr != nil {
  44. return nil, s.rangeErr
  45. }
  46. if s.rangeStats != nil {
  47. return s.rangeStats, nil
  48. }
  49. return s.stats, nil
  50. }
  51. type dashboardCacheStub struct {
  52. get func(ctx context.Context) (string, error)
  53. set func(ctx context.Context, data string, ttl time.Duration) error
  54. del func(ctx context.Context) error
  55. getCalls int32
  56. setCalls int32
  57. delCalls int32
  58. lastSetMu sync.Mutex
  59. lastSet string
  60. }
  61. func (c *dashboardCacheStub) GetDashboardStats(ctx context.Context) (string, error) {
  62. atomic.AddInt32(&c.getCalls, 1)
  63. if c.get != nil {
  64. return c.get(ctx)
  65. }
  66. return "", ErrDashboardStatsCacheMiss
  67. }
  68. func (c *dashboardCacheStub) SetDashboardStats(ctx context.Context, data string, ttl time.Duration) error {
  69. atomic.AddInt32(&c.setCalls, 1)
  70. c.lastSetMu.Lock()
  71. c.lastSet = data
  72. c.lastSetMu.Unlock()
  73. if c.set != nil {
  74. return c.set(ctx, data, ttl)
  75. }
  76. return nil
  77. }
  78. func (c *dashboardCacheStub) DeleteDashboardStats(ctx context.Context) error {
  79. atomic.AddInt32(&c.delCalls, 1)
  80. if c.del != nil {
  81. return c.del(ctx)
  82. }
  83. return nil
  84. }
  85. type dashboardAggregationRepoStub struct {
  86. watermark time.Time
  87. err error
  88. }
  89. func (s *dashboardAggregationRepoStub) AggregateRange(ctx context.Context, start, end time.Time) error {
  90. return nil
  91. }
  92. func (s *dashboardAggregationRepoStub) RecomputeRange(ctx context.Context, start, end time.Time) error {
  93. return nil
  94. }
  95. func (s *dashboardAggregationRepoStub) GetAggregationWatermark(ctx context.Context) (time.Time, error) {
  96. if s.err != nil {
  97. return time.Time{}, s.err
  98. }
  99. return s.watermark, nil
  100. }
  101. func (s *dashboardAggregationRepoStub) UpdateAggregationWatermark(ctx context.Context, aggregatedAt time.Time) error {
  102. return nil
  103. }
  104. func (s *dashboardAggregationRepoStub) CleanupAggregates(ctx context.Context, hourlyCutoff, dailyCutoff time.Time) error {
  105. return nil
  106. }
  107. func (s *dashboardAggregationRepoStub) CleanupUsageLogs(ctx context.Context, cutoff time.Time) error {
  108. return nil
  109. }
  110. func (s *dashboardAggregationRepoStub) CleanupUsageBillingDedup(ctx context.Context, cutoff time.Time) error {
  111. return nil
  112. }
  113. func (s *dashboardAggregationRepoStub) EnsureUsageLogsPartitions(ctx context.Context, now time.Time) error {
  114. return nil
  115. }
  116. func (c *dashboardCacheStub) readLastEntry(t *testing.T) dashboardStatsCacheEntry {
  117. t.Helper()
  118. c.lastSetMu.Lock()
  119. data := c.lastSet
  120. c.lastSetMu.Unlock()
  121. var entry dashboardStatsCacheEntry
  122. err := json.Unmarshal([]byte(data), &entry)
  123. require.NoError(t, err)
  124. return entry
  125. }
  126. func TestDashboardService_CacheHitFresh(t *testing.T) {
  127. stats := &usagestats.DashboardStats{
  128. TotalUsers: 10,
  129. StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
  130. StatsStale: true,
  131. }
  132. entry := dashboardStatsCacheEntry{
  133. Stats: stats,
  134. UpdatedAt: time.Now().Unix(),
  135. }
  136. payload, err := json.Marshal(entry)
  137. require.NoError(t, err)
  138. cache := &dashboardCacheStub{
  139. get: func(ctx context.Context) (string, error) {
  140. return string(payload), nil
  141. },
  142. }
  143. repo := &usageRepoStub{
  144. stats: &usagestats.DashboardStats{TotalUsers: 99},
  145. }
  146. aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
  147. cfg := &config.Config{
  148. Dashboard: config.DashboardCacheConfig{Enabled: true},
  149. DashboardAgg: config.DashboardAggregationConfig{
  150. Enabled: true,
  151. },
  152. }
  153. svc := NewDashboardService(repo, aggRepo, cache, cfg)
  154. got, err := svc.GetDashboardStats(context.Background())
  155. require.NoError(t, err)
  156. require.Equal(t, stats, got)
  157. require.Equal(t, int32(0), atomic.LoadInt32(&repo.calls))
  158. require.Equal(t, int32(1), atomic.LoadInt32(&cache.getCalls))
  159. require.Equal(t, int32(0), atomic.LoadInt32(&cache.setCalls))
  160. }
  161. func TestDashboardService_CacheMiss_StoresCache(t *testing.T) {
  162. stats := &usagestats.DashboardStats{
  163. TotalUsers: 7,
  164. StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
  165. StatsStale: true,
  166. }
  167. cache := &dashboardCacheStub{
  168. get: func(ctx context.Context) (string, error) {
  169. return "", ErrDashboardStatsCacheMiss
  170. },
  171. }
  172. repo := &usageRepoStub{stats: stats}
  173. aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
  174. cfg := &config.Config{
  175. Dashboard: config.DashboardCacheConfig{Enabled: true},
  176. DashboardAgg: config.DashboardAggregationConfig{
  177. Enabled: true,
  178. },
  179. }
  180. svc := NewDashboardService(repo, aggRepo, cache, cfg)
  181. got, err := svc.GetDashboardStats(context.Background())
  182. require.NoError(t, err)
  183. require.Equal(t, stats, got)
  184. require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls))
  185. require.Equal(t, int32(1), atomic.LoadInt32(&cache.getCalls))
  186. require.Equal(t, int32(1), atomic.LoadInt32(&cache.setCalls))
  187. entry := cache.readLastEntry(t)
  188. require.Equal(t, stats, entry.Stats)
  189. require.WithinDuration(t, time.Now(), time.Unix(entry.UpdatedAt, 0), time.Second)
  190. }
  191. func TestDashboardService_CacheDisabled_SkipsCache(t *testing.T) {
  192. stats := &usagestats.DashboardStats{
  193. TotalUsers: 3,
  194. StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
  195. StatsStale: true,
  196. }
  197. cache := &dashboardCacheStub{
  198. get: func(ctx context.Context) (string, error) {
  199. return "", nil
  200. },
  201. }
  202. repo := &usageRepoStub{stats: stats}
  203. aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
  204. cfg := &config.Config{
  205. Dashboard: config.DashboardCacheConfig{Enabled: false},
  206. DashboardAgg: config.DashboardAggregationConfig{
  207. Enabled: true,
  208. },
  209. }
  210. svc := NewDashboardService(repo, aggRepo, cache, cfg)
  211. got, err := svc.GetDashboardStats(context.Background())
  212. require.NoError(t, err)
  213. require.Equal(t, stats, got)
  214. require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls))
  215. require.Equal(t, int32(0), atomic.LoadInt32(&cache.getCalls))
  216. require.Equal(t, int32(0), atomic.LoadInt32(&cache.setCalls))
  217. }
  218. func TestDashboardService_CacheHitStale_TriggersAsyncRefresh(t *testing.T) {
  219. staleStats := &usagestats.DashboardStats{
  220. TotalUsers: 11,
  221. StatsUpdatedAt: time.Unix(0, 0).UTC().Format(time.RFC3339),
  222. StatsStale: true,
  223. }
  224. entry := dashboardStatsCacheEntry{
  225. Stats: staleStats,
  226. UpdatedAt: time.Now().Add(-defaultDashboardStatsFreshTTL * 2).Unix(),
  227. }
  228. payload, err := json.Marshal(entry)
  229. require.NoError(t, err)
  230. cache := &dashboardCacheStub{
  231. get: func(ctx context.Context) (string, error) {
  232. return string(payload), nil
  233. },
  234. }
  235. refreshCh := make(chan struct{}, 1)
  236. repo := &usageRepoStub{
  237. stats: &usagestats.DashboardStats{TotalUsers: 22},
  238. onCall: refreshCh,
  239. }
  240. aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
  241. cfg := &config.Config{
  242. Dashboard: config.DashboardCacheConfig{Enabled: true},
  243. DashboardAgg: config.DashboardAggregationConfig{
  244. Enabled: true,
  245. },
  246. }
  247. svc := NewDashboardService(repo, aggRepo, cache, cfg)
  248. got, err := svc.GetDashboardStats(context.Background())
  249. require.NoError(t, err)
  250. require.Equal(t, staleStats, got)
  251. select {
  252. case <-refreshCh:
  253. case <-time.After(1 * time.Second):
  254. t.Fatal("等待异步刷新超时")
  255. }
  256. require.Eventually(t, func() bool {
  257. return atomic.LoadInt32(&cache.setCalls) >= 1
  258. }, 1*time.Second, 10*time.Millisecond)
  259. }
  260. func TestDashboardService_CacheParseError_EvictsAndRefetches(t *testing.T) {
  261. cache := &dashboardCacheStub{
  262. get: func(ctx context.Context) (string, error) {
  263. return "not-json", nil
  264. },
  265. }
  266. stats := &usagestats.DashboardStats{TotalUsers: 9}
  267. repo := &usageRepoStub{stats: stats}
  268. aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
  269. cfg := &config.Config{
  270. Dashboard: config.DashboardCacheConfig{Enabled: true},
  271. DashboardAgg: config.DashboardAggregationConfig{
  272. Enabled: true,
  273. },
  274. }
  275. svc := NewDashboardService(repo, aggRepo, cache, cfg)
  276. got, err := svc.GetDashboardStats(context.Background())
  277. require.NoError(t, err)
  278. require.Equal(t, stats, got)
  279. require.Equal(t, int32(1), atomic.LoadInt32(&cache.delCalls))
  280. require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls))
  281. }
  282. func TestDashboardService_CacheParseError_RepoFailure(t *testing.T) {
  283. cache := &dashboardCacheStub{
  284. get: func(ctx context.Context) (string, error) {
  285. return "not-json", nil
  286. },
  287. }
  288. repo := &usageRepoStub{err: errors.New("db down")}
  289. aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
  290. cfg := &config.Config{
  291. Dashboard: config.DashboardCacheConfig{Enabled: true},
  292. DashboardAgg: config.DashboardAggregationConfig{
  293. Enabled: true,
  294. },
  295. }
  296. svc := NewDashboardService(repo, aggRepo, cache, cfg)
  297. _, err := svc.GetDashboardStats(context.Background())
  298. require.Error(t, err)
  299. require.Equal(t, int32(1), atomic.LoadInt32(&cache.delCalls))
  300. }
  301. func TestDashboardService_StatsUpdatedAtEpochWhenMissing(t *testing.T) {
  302. stats := &usagestats.DashboardStats{}
  303. repo := &usageRepoStub{stats: stats}
  304. aggRepo := &dashboardAggregationRepoStub{watermark: time.Unix(0, 0).UTC()}
  305. cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: false}}
  306. svc := NewDashboardService(repo, aggRepo, nil, cfg)
  307. got, err := svc.GetDashboardStats(context.Background())
  308. require.NoError(t, err)
  309. require.Equal(t, "1970-01-01T00:00:00Z", got.StatsUpdatedAt)
  310. require.True(t, got.StatsStale)
  311. }
  312. func TestDashboardService_StatsStaleFalseWhenFresh(t *testing.T) {
  313. aggNow := time.Now().UTC().Truncate(time.Second)
  314. stats := &usagestats.DashboardStats{}
  315. repo := &usageRepoStub{stats: stats}
  316. aggRepo := &dashboardAggregationRepoStub{watermark: aggNow}
  317. cfg := &config.Config{
  318. Dashboard: config.DashboardCacheConfig{Enabled: false},
  319. DashboardAgg: config.DashboardAggregationConfig{
  320. Enabled: true,
  321. IntervalSeconds: 60,
  322. LookbackSeconds: 120,
  323. },
  324. }
  325. svc := NewDashboardService(repo, aggRepo, nil, cfg)
  326. got, err := svc.GetDashboardStats(context.Background())
  327. require.NoError(t, err)
  328. require.Equal(t, aggNow.Format(time.RFC3339), got.StatsUpdatedAt)
  329. require.False(t, got.StatsStale)
  330. }
  331. func TestDashboardService_AggDisabled_UsesUsageLogsFallback(t *testing.T) {
  332. expected := &usagestats.DashboardStats{TotalUsers: 42}
  333. repo := &usageRepoStub{
  334. rangeStats: expected,
  335. err: errors.New("should not call aggregated stats"),
  336. }
  337. cfg := &config.Config{
  338. Dashboard: config.DashboardCacheConfig{Enabled: false},
  339. DashboardAgg: config.DashboardAggregationConfig{
  340. Enabled: false,
  341. Retention: config.DashboardAggregationRetentionConfig{
  342. UsageLogsDays: 7,
  343. },
  344. },
  345. }
  346. svc := NewDashboardService(repo, nil, nil, cfg)
  347. got, err := svc.GetDashboardStats(context.Background())
  348. require.NoError(t, err)
  349. require.Equal(t, int64(42), got.TotalUsers)
  350. require.Equal(t, int32(0), atomic.LoadInt32(&repo.calls))
  351. require.Equal(t, int32(1), atomic.LoadInt32(&repo.rangeCalls))
  352. require.False(t, repo.rangeEnd.IsZero())
  353. require.Equal(t, truncateToDayUTC(repo.rangeEnd.AddDate(0, 0, -7)), repo.rangeStart)
  354. }