- TopK initial filter now drops quota-paused accounts: fold the quota check into isAccountRequestCompatible so session-hash, TopK pool, and per-candidate rechecks all skip paused accounts. Previously the candidate pool was built without the quota check, so paused accounts could fill TopK and leave the scheduler returning "no available accounts" even with healthy ones available. - Add per-account explicit disable flags auto_pause_5h_disabled / auto_pause_7d_disabled with toggles in EditAccountModal. Without these, leaving the account threshold blank silently falls back to the global default, so admins could not exempt a single account once a global default existed. Disable is per-window: an account can opt out of 5h auto-pause while still honoring 7d. Schedule snapshot whitelist includes the new fields, i18n EN/ZH updated, threshold-hint text revised to explain "blank = global default". - Move quota auto-pause settings off the request hot path: replace the per-repo TTL+singleflight sync DB read with a per-SettingService stale-while-revalidate in-memory snapshot. Get is non-blocking (atomic.Pointer load + async refresh on staleness); writes via UpdateOpsAdvancedSettings push directly into the cache through an injected sink; wire warms the cache at startup. Adds Warm (sync) for tests/init and SetOpenAIQuotaAutoPauseSettings (sink target). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
162 lines
5.7 KiB
Go
162 lines
5.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
)
|
|
|
|
func TestGetOpsAdvancedSettings_DefaultHidesOpenAITokenStats(t *testing.T) {
|
|
repo := newRuntimeSettingRepoStub()
|
|
svc := &OpsService{settingRepo: repo}
|
|
|
|
cfg, err := svc.GetOpsAdvancedSettings(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetOpsAdvancedSettings() error = %v", err)
|
|
}
|
|
if cfg.DisplayOpenAITokenStats {
|
|
t.Fatalf("DisplayOpenAITokenStats = true, want false by default")
|
|
}
|
|
if !cfg.DisplayAlertEvents {
|
|
t.Fatalf("DisplayAlertEvents = false, want true by default")
|
|
}
|
|
if repo.setCalls != 1 {
|
|
t.Fatalf("expected defaults to be persisted once, got %d", repo.setCalls)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOpsAdvancedSettings_PersistsOpenAITokenStatsVisibility(t *testing.T) {
|
|
repo := newRuntimeSettingRepoStub()
|
|
svc := &OpsService{settingRepo: repo}
|
|
|
|
cfg := defaultOpsAdvancedSettings()
|
|
cfg.DisplayOpenAITokenStats = true
|
|
cfg.DisplayAlertEvents = false
|
|
|
|
updated, err := svc.UpdateOpsAdvancedSettings(context.Background(), cfg)
|
|
if err != nil {
|
|
t.Fatalf("UpdateOpsAdvancedSettings() error = %v", err)
|
|
}
|
|
if !updated.DisplayOpenAITokenStats {
|
|
t.Fatalf("DisplayOpenAITokenStats = false, want true")
|
|
}
|
|
if updated.DisplayAlertEvents {
|
|
t.Fatalf("DisplayAlertEvents = true, want false")
|
|
}
|
|
|
|
reloaded, err := svc.GetOpsAdvancedSettings(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetOpsAdvancedSettings() after update error = %v", err)
|
|
}
|
|
if !reloaded.DisplayOpenAITokenStats {
|
|
t.Fatalf("reloaded DisplayOpenAITokenStats = false, want true")
|
|
}
|
|
if reloaded.DisplayAlertEvents {
|
|
t.Fatalf("reloaded DisplayAlertEvents = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestGetOpsAdvancedSettings_BackfillsNewDisplayFlagsFromDefaults(t *testing.T) {
|
|
repo := newRuntimeSettingRepoStub()
|
|
svc := &OpsService{settingRepo: repo}
|
|
|
|
legacyCfg := map[string]any{
|
|
"data_retention": map[string]any{
|
|
"cleanup_enabled": false,
|
|
"cleanup_schedule": "0 2 * * *",
|
|
"error_log_retention_days": 30,
|
|
"minute_metrics_retention_days": 30,
|
|
"hourly_metrics_retention_days": 30,
|
|
},
|
|
"aggregation": map[string]any{
|
|
"aggregation_enabled": false,
|
|
},
|
|
"ignore_count_tokens_errors": true,
|
|
"ignore_context_canceled": true,
|
|
"ignore_no_available_accounts": false,
|
|
"ignore_invalid_api_key_errors": false,
|
|
"auto_refresh_enabled": false,
|
|
"auto_refresh_interval_seconds": 30,
|
|
}
|
|
raw, err := json.Marshal(legacyCfg)
|
|
if err != nil {
|
|
t.Fatalf("marshal legacy config: %v", err)
|
|
}
|
|
repo.values[SettingKeyOpsAdvancedSettings] = string(raw)
|
|
|
|
cfg, err := svc.GetOpsAdvancedSettings(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetOpsAdvancedSettings() error = %v", err)
|
|
}
|
|
if cfg.DisplayOpenAITokenStats {
|
|
t.Fatalf("DisplayOpenAITokenStats = true, want false default backfill")
|
|
}
|
|
if !cfg.DisplayAlertEvents {
|
|
t.Fatalf("DisplayAlertEvents = false, want true default backfill")
|
|
}
|
|
}
|
|
|
|
func TestGetOpenAIQuotaAutoPauseSettings_ReadsDefaultsFromOpsAdvancedSettings(t *testing.T) {
|
|
repo := newRuntimeSettingRepoStub()
|
|
repo.values[SettingKeyOpsAdvancedSettings] = `{"openai_account_quota_auto_pause":{"default_threshold_5h":0.95,"default_threshold_7d":0.9}}`
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
// Warm the in-memory cache synchronously so the assertion below is deterministic.
|
|
// GetOpenAIQuotaAutoPauseSettings is non-blocking on the hot path (returns the
|
|
// cached value, refreshes asynchronously); for tests and startup, Warm is the
|
|
// synchronous entry point that guarantees a populated cache.
|
|
settings := svc.WarmOpenAIQuotaAutoPauseSettings(context.Background())
|
|
if settings.DefaultThreshold5h != 0.95 {
|
|
t.Fatalf("DefaultThreshold5h = %v, want 0.95", settings.DefaultThreshold5h)
|
|
}
|
|
if settings.DefaultThreshold7d != 0.9 {
|
|
t.Fatalf("DefaultThreshold7d = %v, want 0.9", settings.DefaultThreshold7d)
|
|
}
|
|
|
|
// Subsequent Get must hit the warm cache and return the same value without any DB
|
|
// access — that's the hot-path invariant.
|
|
cached := svc.GetOpenAIQuotaAutoPauseSettings(context.Background())
|
|
if cached.DefaultThreshold5h != 0.95 || cached.DefaultThreshold7d != 0.9 {
|
|
t.Fatalf("cached read = %+v, want {0.95, 0.9}", cached)
|
|
}
|
|
}
|
|
|
|
// Hot-path invariant: a Get with cold cache must return immediately (zero defaults)
|
|
// rather than blocking on the DB. The async refresher will populate the cache for
|
|
// subsequent calls.
|
|
func TestGetOpenAIQuotaAutoPauseSettings_ColdCacheNonBlocking(t *testing.T) {
|
|
repo := newRuntimeSettingRepoStub()
|
|
repo.values[SettingKeyOpsAdvancedSettings] = `{"openai_account_quota_auto_pause":{"default_threshold_5h":0.7}}`
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
start := time.Now()
|
|
settings := svc.GetOpenAIQuotaAutoPauseSettings(context.Background())
|
|
elapsed := time.Since(start)
|
|
if elapsed > 50*time.Millisecond {
|
|
t.Fatalf("cold-cache Get must be non-blocking, took %v", elapsed)
|
|
}
|
|
// Cold cache means we get zero defaults (the async refresh hasn't completed yet).
|
|
if settings.DefaultThreshold5h != 0 || settings.DefaultThreshold7d != 0 {
|
|
t.Fatalf("cold-cache Get = %+v, want zeroes", settings)
|
|
}
|
|
}
|
|
|
|
// Explicit cache write (e.g. from UpdateOpsAdvancedSettings) must be visible on the
|
|
// very next read without any DB roundtrip.
|
|
func TestSetOpenAIQuotaAutoPauseSettings_VisibleImmediately(t *testing.T) {
|
|
svc := NewSettingService(newRuntimeSettingRepoStub(), &config.Config{})
|
|
|
|
svc.SetOpenAIQuotaAutoPauseSettings(OpsOpenAIAccountQuotaAutoPauseSettings{
|
|
DefaultThreshold5h: 0.88,
|
|
DefaultThreshold7d: 0.77,
|
|
})
|
|
|
|
got := svc.GetOpenAIQuotaAutoPauseSettings(context.Background())
|
|
if got.DefaultThreshold5h != 0.88 || got.DefaultThreshold7d != 0.77 {
|
|
t.Fatalf("after Set, Get = %+v, want {0.88, 0.77}", got)
|
|
}
|
|
}
|