ops_settings_advanced_test.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. )
  9. func TestGetOpsAdvancedSettings_DefaultSnapshotHidesOpenAITokenStats(t *testing.T) {
  10. repo := newRuntimeSettingRepoStub()
  11. svc := &OpsService{settingRepo: repo}
  12. cfg, err := svc.GetOpsAdvancedSettings(context.Background())
  13. if err != nil {
  14. t.Fatalf("GetOpsAdvancedSettings() error = %v", err)
  15. }
  16. if cfg.DisplayOpenAITokenStats {
  17. t.Fatalf("DisplayOpenAITokenStats = true, want false by default")
  18. }
  19. if !cfg.DisplayAlertEvents {
  20. t.Fatalf("DisplayAlertEvents = false, want true by default")
  21. }
  22. if !cfg.DataRetention.CleanupEnabled {
  23. t.Fatal("data cleanup should be enabled by default")
  24. }
  25. if repo.getValueCalls != 0 || repo.getMultipleCalls != 0 {
  26. t.Fatalf("hot-path snapshot read touched repository: get=%d get_multiple=%d", repo.getValueCalls, repo.getMultipleCalls)
  27. }
  28. }
  29. func TestGetOpsAdvancedSettings_DefaultCleanupFollowsDeploymentConfig(t *testing.T) {
  30. svc := &OpsService{cfg: &config.Config{Ops: config.OpsConfig{
  31. Cleanup: config.OpsCleanupConfig{Enabled: false},
  32. }}}
  33. svc.initRuntimeSettings(context.Background())
  34. cfg, err := svc.GetOpsAdvancedSettings(context.Background())
  35. if err != nil {
  36. t.Fatalf("GetOpsAdvancedSettings() error = %v", err)
  37. }
  38. if cfg.DataRetention.CleanupEnabled {
  39. t.Fatal("data cleanup should follow the disabled deployment baseline")
  40. }
  41. }
  42. func TestUpdateOpsAdvancedSettings_PersistsOpenAITokenStatsVisibility(t *testing.T) {
  43. repo := newRuntimeSettingRepoStub()
  44. svc := &OpsService{settingRepo: repo}
  45. cfg := defaultOpsAdvancedSettings()
  46. cfg.DisplayOpenAITokenStats = true
  47. cfg.DisplayAlertEvents = false
  48. updated, err := svc.UpdateOpsAdvancedSettings(context.Background(), cfg)
  49. if err != nil {
  50. t.Fatalf("UpdateOpsAdvancedSettings() error = %v", err)
  51. }
  52. if !updated.DisplayOpenAITokenStats {
  53. t.Fatalf("DisplayOpenAITokenStats = false, want true")
  54. }
  55. if updated.DisplayAlertEvents {
  56. t.Fatalf("DisplayAlertEvents = true, want false")
  57. }
  58. readsAfterUpdate := repo.getValueCalls + repo.getMultipleCalls
  59. reloaded, err := svc.GetOpsAdvancedSettings(context.Background())
  60. if err != nil {
  61. t.Fatalf("GetOpsAdvancedSettings() after update error = %v", err)
  62. }
  63. if !reloaded.DisplayOpenAITokenStats {
  64. t.Fatalf("reloaded DisplayOpenAITokenStats = false, want true")
  65. }
  66. if reloaded.DisplayAlertEvents {
  67. t.Fatalf("reloaded DisplayAlertEvents = true, want false")
  68. }
  69. if got := repo.getValueCalls + repo.getMultipleCalls; got != readsAfterUpdate {
  70. t.Fatalf("snapshot reload performed repository read: before=%d after=%d", readsAfterUpdate, got)
  71. }
  72. }
  73. func TestGetOpsAdvancedSettings_BackfillsNewDisplayFlagsFromDefaults(t *testing.T) {
  74. repo := newRuntimeSettingRepoStub()
  75. svc := &OpsService{settingRepo: repo}
  76. legacyCfg := map[string]any{
  77. "data_retention": map[string]any{
  78. "cleanup_enabled": false,
  79. "cleanup_schedule": "0 2 * * *",
  80. "error_log_retention_days": 30,
  81. "minute_metrics_retention_days": 30,
  82. "hourly_metrics_retention_days": 30,
  83. },
  84. "aggregation": map[string]any{
  85. "aggregation_enabled": false,
  86. },
  87. "ignore_count_tokens_errors": true,
  88. "ignore_context_canceled": true,
  89. "ignore_no_available_accounts": false,
  90. "ignore_invalid_api_key_errors": true,
  91. "auto_refresh_enabled": false,
  92. "auto_refresh_interval_seconds": 30,
  93. }
  94. raw, err := json.Marshal(legacyCfg)
  95. if err != nil {
  96. t.Fatalf("marshal legacy config: %v", err)
  97. }
  98. repo.values[SettingKeyOpsAdvancedSettings] = string(raw)
  99. cfg, err := svc.GetOpsAdvancedSettings(context.Background())
  100. if err != nil {
  101. t.Fatalf("GetOpsAdvancedSettings() error = %v", err)
  102. }
  103. if cfg.DisplayOpenAITokenStats {
  104. t.Fatalf("DisplayOpenAITokenStats = true, want false default backfill")
  105. }
  106. if !cfg.DisplayAlertEvents {
  107. t.Fatalf("DisplayAlertEvents = false, want true default backfill")
  108. }
  109. }
  110. func TestGetOpenAIQuotaAutoPauseSettings_ReadsDefaultsFromOpsAdvancedSettings(t *testing.T) {
  111. repo := newRuntimeSettingRepoStub()
  112. repo.values[SettingKeyOpsAdvancedSettings] = `{"openai_account_quota_auto_pause":{"default_threshold_5h":0.95,"default_threshold_7d":0.9}}`
  113. svc := NewSettingService(repo, &config.Config{})
  114. // Warm the in-memory cache synchronously so the assertion below is deterministic.
  115. // GetOpenAIQuotaAutoPauseSettings is non-blocking on the hot path (returns the
  116. // cached value, refreshes asynchronously); for tests and startup, Warm is the
  117. // synchronous entry point that guarantees a populated cache.
  118. settings := svc.WarmOpenAIQuotaAutoPauseSettings(context.Background())
  119. if settings.DefaultThreshold5h != 0.95 {
  120. t.Fatalf("DefaultThreshold5h = %v, want 0.95", settings.DefaultThreshold5h)
  121. }
  122. if settings.DefaultThreshold7d != 0.9 {
  123. t.Fatalf("DefaultThreshold7d = %v, want 0.9", settings.DefaultThreshold7d)
  124. }
  125. // Subsequent Get must hit the warm cache and return the same value without any DB
  126. // access — that's the hot-path invariant.
  127. cached := svc.GetOpenAIQuotaAutoPauseSettings(context.Background())
  128. if cached.DefaultThreshold5h != 0.95 || cached.DefaultThreshold7d != 0.9 {
  129. t.Fatalf("cached read = %+v, want {0.95, 0.9}", cached)
  130. }
  131. }
  132. // Hot-path invariant: a Get with cold cache must return immediately (zero defaults)
  133. // rather than blocking on the DB. The async refresher will populate the cache for
  134. // subsequent calls.
  135. func TestGetOpenAIQuotaAutoPauseSettings_ColdCacheNonBlocking(t *testing.T) {
  136. repo := newRuntimeSettingRepoStub()
  137. repo.values[SettingKeyOpsAdvancedSettings] = `{"openai_account_quota_auto_pause":{"default_threshold_5h":0.7}}`
  138. svc := NewSettingService(repo, &config.Config{})
  139. start := time.Now()
  140. settings := svc.GetOpenAIQuotaAutoPauseSettings(context.Background())
  141. elapsed := time.Since(start)
  142. if elapsed > 50*time.Millisecond {
  143. t.Fatalf("cold-cache Get must be non-blocking, took %v", elapsed)
  144. }
  145. // Cold cache means we get zero defaults (the async refresh hasn't completed yet).
  146. if settings.DefaultThreshold5h != 0 || settings.DefaultThreshold7d != 0 {
  147. t.Fatalf("cold-cache Get = %+v, want zeroes", settings)
  148. }
  149. }
  150. // Explicit cache write (e.g. from UpdateOpsAdvancedSettings) must be visible on the
  151. // very next read without any DB roundtrip.
  152. func TestSetOpenAIQuotaAutoPauseSettings_VisibleImmediately(t *testing.T) {
  153. svc := NewSettingService(newRuntimeSettingRepoStub(), &config.Config{})
  154. svc.SetOpenAIQuotaAutoPauseSettings(OpsOpenAIAccountQuotaAutoPauseSettings{
  155. DefaultThreshold5h: 0.88,
  156. DefaultThreshold7d: 0.77,
  157. })
  158. got := svc.GetOpenAIQuotaAutoPauseSettings(context.Background())
  159. if got.DefaultThreshold5h != 0.88 || got.DefaultThreshold7d != 0.77 {
  160. t.Fatalf("after Set, Get = %+v, want {0.88, 0.77}", got)
  161. }
  162. }