package config import ( "context" "os" "path/filepath" "testing" "time" ) func TestLoadPrefersEnvLLMAPIKey(t *testing.T) { t.Setenv("LLM_API_KEY", "env-priority-key") configPath := writeTestConfig(t, ` llm: provider: ark api_key: "config-file-key" `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.LLM.APIKey != "env-priority-key" { t.Fatalf("expected env api key, got %q", cfg.LLM.APIKey) } } func TestDatabaseConfigDSNEscapesCredentials(t *testing.T) { cfg := DatabaseConfig{ Host: "db.internal", Port: 5432, User: "geo", Password: "p@ss:word/with?symbols", DBName: "geo", SSLMode: "disable", } got := cfg.DSN() want := "postgres://geo:p%40ss%3Aword%2Fwith%3Fsymbols@db.internal:5432/geo?sslmode=disable" if got != want { t.Fatalf("DSN() = %q, want %q", got, want) } } func TestLoadFallsBackToConfigLLMAPIKey(t *testing.T) { t.Setenv("LLM_API_KEY", "") configPath := writeTestConfig(t, ` llm: provider: ark api_key: "config-file-key" `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.LLM.APIKey != "config-file-key" { t.Fatalf("expected config api key, got %q", cfg.LLM.APIKey) } } func TestLoadPrefersEnvRetrievalAndQdrantSettings(t *testing.T) { t.Setenv("SILICONFLOW_API_KEY", "siliconflow-env-key") t.Setenv("SILICONFLOW_EMBEDDING_MODEL", "env-embedding") t.Setenv("SILICONFLOW_RERANKER_MODEL", "env-reranker") t.Setenv("QDRANT_API_KEY", "qdrant-env-key") t.Setenv("QDRANT_URL", "qdrant-env:6334") t.Setenv("QDRANT_COLLECTION", "env_collection") configPath := writeTestConfig(t, ` qdrant: url: qdrant-config:6334 api_key: "qdrant-config-key" collection: "config_collection" retrieval: provider: siliconflow api_key: "retrieval-config-key" embedding_model: "config-embedding" reranker_model: "config-reranker" `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Retrieval.APIKey != "siliconflow-env-key" { t.Fatalf("expected env retrieval api key, got %q", cfg.Retrieval.APIKey) } if cfg.Retrieval.EmbeddingModel != "env-embedding" { t.Fatalf("expected env embedding model, got %q", cfg.Retrieval.EmbeddingModel) } if cfg.Retrieval.RerankerModel != "env-reranker" { t.Fatalf("expected env reranker model, got %q", cfg.Retrieval.RerankerModel) } if cfg.Qdrant.APIKey != "qdrant-env-key" { t.Fatalf("expected env qdrant api key, got %q", cfg.Qdrant.APIKey) } if cfg.Qdrant.URL != "qdrant-env:6334" { t.Fatalf("expected env qdrant url, got %q", cfg.Qdrant.URL) } if cfg.Qdrant.Collection != "env_collection" { t.Fatalf("expected env qdrant collection, got %q", cfg.Qdrant.Collection) } } func TestLoadAppliesBrandLibraryDefaults(t *testing.T) { configPath := writeTestConfig(t, ` brand_library: {} `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.BrandLibrary.FreeBrandLimit != 1 { t.Fatalf("expected default free brand limit 1, got %d", cfg.BrandLibrary.FreeBrandLimit) } if cfg.BrandLibrary.PaidBrandLimit != 2 { t.Fatalf("expected default paid brand limit 2, got %d", cfg.BrandLibrary.PaidBrandLimit) } if cfg.BrandLibrary.MaxKeywords != 5 { t.Fatalf("expected default max keywords 5, got %d", cfg.BrandLibrary.MaxKeywords) } if cfg.BrandLibrary.MaxQuestionsPerKeyword != 5 { t.Fatalf("expected default max questions per keyword 5, got %d", cfg.BrandLibrary.MaxQuestionsPerKeyword) } } func TestLoadAppliesServerTrustedProxyDefaultsAndOverrides(t *testing.T) { defaultConfigPath := writeTestConfig(t, ` server: {} `) cfg, err := Load(defaultConfigPath) if err != nil { t.Fatalf("Load() default error = %v", err) } if got := cfg.Server.TrustedProxies; len(got) != len(DefaultTrustedProxies()) { t.Fatalf("expected default trusted proxies, got %#v", got) } overrideConfigPath := writeTestConfig(t, ` server: trusted_proxies: - " 10.42.0.0/16 " - "" - "192.168.1.10" `) cfg, err = Load(overrideConfigPath) if err != nil { t.Fatalf("Load() override error = %v", err) } want := []string{"10.42.0.0/16", "192.168.1.10"} if len(cfg.Server.TrustedProxies) != len(want) { t.Fatalf("trusted proxies = %#v, want %#v", cfg.Server.TrustedProxies, want) } for i := range want { if cfg.Server.TrustedProxies[i] != want[i] { t.Fatalf("trusted proxies = %#v, want %#v", cfg.Server.TrustedProxies, want) } } } func TestLoadAppliesSchedulerHTTPDefaultsAndDisableSentinel(t *testing.T) { defaultConfigPath := writeTestConfig(t, ` scheduler: {} `) cfg, err := Load(defaultConfigPath) if err != nil { t.Fatalf("Load() default error = %v", err) } if cfg.Scheduler.HTTPHost != "127.0.0.1" { t.Fatalf("expected default scheduler http host 127.0.0.1, got %q", cfg.Scheduler.HTTPHost) } if cfg.Scheduler.HTTPPort != 8081 { t.Fatalf("expected default scheduler http port 8081, got %d", cfg.Scheduler.HTTPPort) } disabledConfigPath := writeTestConfig(t, ` scheduler: http_port: -1 `) cfg, err = Load(disabledConfigPath) if err != nil { t.Fatalf("Load() disabled error = %v", err) } if cfg.Scheduler.HTTPPort != -1 { t.Fatalf("expected scheduler http port -1 to disable server, got %d", cfg.Scheduler.HTTPPort) } } func TestLoadAppliesRedisAndCacheDefaults(t *testing.T) { configPath := writeTestConfig(t, ` redis: {} cache: {} `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Redis.Addr != "localhost:6379" { t.Fatalf("expected default redis addr, got %q", cfg.Redis.Addr) } if cfg.Redis.DialTimeout != 5*time.Second { t.Fatalf("expected default redis dial timeout 5s, got %s", cfg.Redis.DialTimeout) } if cfg.Redis.ReadTimeout != 3*time.Second { t.Fatalf("expected default redis read timeout 3s, got %s", cfg.Redis.ReadTimeout) } if cfg.Redis.WriteTimeout != 3*time.Second { t.Fatalf("expected default redis write timeout 3s, got %s", cfg.Redis.WriteTimeout) } if cfg.Redis.PoolSize != 16 { t.Fatalf("expected default redis pool size 16, got %d", cfg.Redis.PoolSize) } if cfg.Redis.PoolTimeout != 4*time.Second { t.Fatalf("expected default redis pool timeout 4s, got %s", cfg.Redis.PoolTimeout) } if cfg.Cache.Driver != "redis" { t.Fatalf("expected default cache driver redis, got %q", cfg.Cache.Driver) } if cfg.Cache.Namespace != "geo" { t.Fatalf("expected default cache namespace geo, got %q", cfg.Cache.Namespace) } if cfg.Cache.JitterRatio != 0.1 { t.Fatalf("expected default jitter ratio 0.1, got %f", cfg.Cache.JitterRatio) } if !cfg.Cache.MetricsEnabled { t.Fatalf("expected cache metrics enabled by default") } if cfg.Cache.AsyncFillWorkers != 2 || cfg.Cache.AsyncFillBuffer != 1024 || cfg.Cache.AsyncFillTimeout != 2*time.Second { t.Fatalf("unexpected async fill defaults: workers=%d buffer=%d timeout=%s", cfg.Cache.AsyncFillWorkers, cfg.Cache.AsyncFillBuffer, cfg.Cache.AsyncFillTimeout) } if cfg.Cache.L1TTL != 30*time.Second { t.Fatalf("expected default l1 ttl 30s, got %s", cfg.Cache.L1TTL) } if cfg.Cache.DeleteScanCount != 500 { t.Fatalf("expected default delete scan count 500, got %d", cfg.Cache.DeleteScanCount) } } func TestLoadAppliesRedisAndCacheOverrides(t *testing.T) { configPath := writeTestConfig(t, ` redis: addr: redis.internal:6380 password: secret db: 2 dial_timeout: 7s read_timeout: 8s write_timeout: 9s pool_size: 32 min_idle_conns: 4 max_retries: 5 pool_timeout: 6s tls_enabled: true cache: driver: memory namespace: tenant-cache jitter_ratio: 0.25 metrics_enabled: false async_fill_enabled: true async_fill_workers: 3 async_fill_buffer: 64 async_fill_timeout: 1500ms l1_enabled: true l1_ttl: 5s delete_scan_count: 1200 `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Redis.Addr != "redis.internal:6380" || cfg.Redis.Password != "secret" || cfg.Redis.DB != 2 { t.Fatalf("unexpected redis endpoint config: %#v", cfg.Redis) } if cfg.Redis.DialTimeout != 7*time.Second || cfg.Redis.ReadTimeout != 8*time.Second || cfg.Redis.WriteTimeout != 9*time.Second { t.Fatalf("unexpected redis timeout config: %#v", cfg.Redis) } if cfg.Redis.PoolSize != 32 || cfg.Redis.MinIdleConns != 4 || cfg.Redis.MaxRetries != 5 || cfg.Redis.PoolTimeout != 6*time.Second { t.Fatalf("unexpected redis pool config: %#v", cfg.Redis) } if cfg.Redis.TLSConfig() == nil { t.Fatalf("expected redis TLS config") } if cfg.Cache.Driver != "memory" || cfg.Cache.Namespace != "tenant-cache" || cfg.Cache.JitterRatio != 0.25 { t.Fatalf("unexpected cache base config: %#v", cfg.Cache) } if cfg.Cache.MetricsEnabled { t.Fatalf("expected cache metrics override false") } if !cfg.Cache.AsyncFillEnabled || cfg.Cache.AsyncFillWorkers != 3 || cfg.Cache.AsyncFillBuffer != 64 || cfg.Cache.AsyncFillTimeout != 1500*time.Millisecond { t.Fatalf("unexpected cache async config: %#v", cfg.Cache) } if !cfg.Cache.L1Enabled || cfg.Cache.L1TTL != 5*time.Second || cfg.Cache.DeleteScanCount != 1200 { t.Fatalf("unexpected cache l1/delete config: %#v", cfg.Cache) } } func TestLoadPrefersEnvSchedulerMetricsSettings(t *testing.T) { t.Setenv("SCHEDULER_HTTP_HOST", "0.0.0.0") t.Setenv("SCHEDULER_INTERNAL_METRICS_TOKEN", "env-metrics-token") configPath := writeTestConfig(t, ` scheduler: http_host: 127.0.0.1 internal_metrics_token: config-token `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Scheduler.HTTPHost != "0.0.0.0" { t.Fatalf("expected env scheduler http host, got %q", cfg.Scheduler.HTTPHost) } if cfg.Scheduler.InternalMetricsToken != "env-metrics-token" { t.Fatalf("expected env scheduler metrics token, got %q", cfg.Scheduler.InternalMetricsToken) } } func TestLoadPrefersEnvJWTSettings(t *testing.T) { t.Setenv("JWT_SECRET", "env-jwt-secret") t.Setenv("JWT_ACCESS_TTL", "20m") t.Setenv("JWT_REFRESH_TTL", "720h") configPath := writeTestConfig(t, ` jwt: secret: config-jwt-secret access_ttl: 15m refresh_ttl: 168h `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.JWT.Secret != "env-jwt-secret" { t.Fatalf("expected env jwt secret, got %q", cfg.JWT.Secret) } if cfg.JWT.AccessTTL != 20*time.Minute { t.Fatalf("expected env jwt access ttl 20m, got %s", cfg.JWT.AccessTTL) } if cfg.JWT.RefreshTTL != 720*time.Hour { t.Fatalf("expected env jwt refresh ttl 720h, got %s", cfg.JWT.RefreshTTL) } } func TestLoadAppliesMembershipDefaults(t *testing.T) { configPath := writeTestConfig(t, ` membership: {} `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Membership.DefaultPlanCode != "free" { t.Fatalf("expected default plan code free, got %q", cfg.Membership.DefaultPlanCode) } if len(cfg.Membership.Plans) != 3 { t.Fatalf("expected 3 default plans, got %d", len(cfg.Membership.Plans)) } free, ok := cfg.Membership.FindPlan("free") if !ok { t.Fatalf("expected free plan to exist") } if free.ArticleGeneration != 3 { t.Fatalf("expected free article generation 3, got %d", free.ArticleGeneration) } if free.ImageStorageBytes != 10*1024*1024 { t.Fatalf("expected free image storage 10MB, got %d", free.ImageStorageBytes) } if free.CompanyLimit != 1 { t.Fatalf("expected free company limit 1, got %d", free.CompanyLimit) } if free.SubscriptionDuration != 72*time.Hour { t.Fatalf("expected free subscription duration 72h, got %s", free.SubscriptionDuration) } if !free.ContactAdminOnExpiry { t.Fatalf("expected free plan to require admin contact on expiry") } pro, ok := cfg.Membership.FindPlan("pro") if !ok { t.Fatalf("expected pro plan to exist") } if pro.ArticleGeneration != 400 { t.Fatalf("expected pro article generation 400, got %d", pro.ArticleGeneration) } if pro.AIPointsMonthly != 1500 { t.Fatalf("expected pro ai points 1500, got %d", pro.AIPointsMonthly) } if pro.AIPointBaseChars != 1000 { t.Fatalf("expected pro ai point base chars 1000, got %d", pro.AIPointBaseChars) } if pro.CompanyLimit != 2 { t.Fatalf("expected pro company limit 2, got %d", pro.CompanyLimit) } } func TestLoadMergesLocalOverrideAndResolvesEnvPlaceholders(t *testing.T) { t.Setenv("CONFIG_TEST_LLM_KEY", "placeholder-key") t.Setenv("LLM_API_KEY", "") dir := t.TempDir() configPath := filepath.Join(dir, "config.yaml") localPath := filepath.Join(dir, "config.local.yaml") writeFile(t, configPath, ` llm: provider: ark api_key: "${CONFIG_TEST_LLM_KEY:missing}" max_output_tokens: 1000 generation: stream_enabled: false `) writeFile(t, localPath, ` generation: stream_enabled: true `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.LLM.APIKey != "placeholder-key" { t.Fatalf("expected placeholder env api key, got %q", cfg.LLM.APIKey) } if !cfg.Generation.StreamEnabled { t.Fatalf("expected local override to enable generation stream") } } func TestLoadAppliesGenerationRecoveryDefaults(t *testing.T) { configPath := writeTestConfig(t, ` generation: article_timeout: 90s `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Generation.QueueSize != 128 { t.Fatalf("expected default queue size 128, got %d", cfg.Generation.QueueSize) } if cfg.Generation.WorkerConcurrency != 1 { t.Fatalf("expected default worker concurrency 1, got %d", cfg.Generation.WorkerConcurrency) } if cfg.Generation.TaskLeaseTTL != 3*time.Minute+30*time.Second { t.Fatalf("expected default task lease ttl article_timeout+2m, got %s", cfg.Generation.TaskLeaseTTL) } if cfg.Generation.TaskRecoveryInterval != time.Minute { t.Fatalf("expected default task recovery interval 1m, got %s", cfg.Generation.TaskRecoveryInterval) } if cfg.Generation.TaskRecoveryTimeout != 30*time.Second { t.Fatalf("expected default task recovery timeout 30s, got %s", cfg.Generation.TaskRecoveryTimeout) } if cfg.Generation.TaskRecoveryBatchSize != 100 { t.Fatalf("expected default task recovery batch size 100, got %d", cfg.Generation.TaskRecoveryBatchSize) } if cfg.Generation.TaskQueuedStaleAfter != 2*time.Minute { t.Fatalf("expected default queued stale after 2m, got %s", cfg.Generation.TaskQueuedStaleAfter) } if cfg.Generation.TaskMaxAttempts != 3 { t.Fatalf("expected default task max attempts 3, got %d", cfg.Generation.TaskMaxAttempts) } if cfg.Generation.TaskStateCheckInterval != 5*time.Minute { t.Fatalf("expected default task state check interval 5m, got %s", cfg.Generation.TaskStateCheckInterval) } if cfg.Generation.TaskStateCheckTimeout != 10*time.Second { t.Fatalf("expected default task state check timeout 10s, got %s", cfg.Generation.TaskStateCheckTimeout) } if cfg.Generation.TaskStateCheckBatchSize != 100 { t.Fatalf("expected default task state check batch size 100, got %d", cfg.Generation.TaskStateCheckBatchSize) } if cfg.Generation.TaskStateCheckLookback != 24*time.Hour { t.Fatalf("expected default task state check lookback 24h, got %s", cfg.Generation.TaskStateCheckLookback) } } func TestLoadAppliesGenerationRecoveryOverrides(t *testing.T) { configPath := writeTestConfig(t, ` generation: queue_size: 64 worker_concurrency: 4 article_timeout: 5m task_lease_ttl: 7m task_recovery_interval: 15s task_recovery_timeout: 10s task_recovery_batch_size: 12 task_queued_stale_after: 45s task_max_attempts: 5 task_state_check_interval: 9m task_state_check_timeout: 11s task_state_check_batch_size: 13 task_state_check_lookback: 2h `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Generation.QueueSize != 64 || cfg.Generation.WorkerConcurrency != 4 || cfg.Generation.ArticleTimeout != 5*time.Minute { t.Fatalf("unexpected generation base config: %#v", cfg.Generation) } if cfg.Generation.TaskLeaseTTL != 7*time.Minute || cfg.Generation.TaskRecoveryInterval != 15*time.Second || cfg.Generation.TaskRecoveryTimeout != 10*time.Second || cfg.Generation.TaskRecoveryBatchSize != 12 || cfg.Generation.TaskQueuedStaleAfter != 45*time.Second || cfg.Generation.TaskMaxAttempts != 5 || cfg.Generation.TaskStateCheckInterval != 9*time.Minute || cfg.Generation.TaskStateCheckTimeout != 11*time.Second || cfg.Generation.TaskStateCheckBatchSize != 13 || cfg.Generation.TaskStateCheckLookback != 2*time.Hour { t.Fatalf("unexpected generation recovery config: %#v", cfg.Generation) } } func TestLoadAppliesGenerationStateCheckEnvOverrides(t *testing.T) { t.Setenv("GENERATION_TASK_STATE_CHECK_INTERVAL", "3m") t.Setenv("GENERATION_TASK_STATE_CHECK_TIMEOUT", "4s") t.Setenv("GENERATION_TASK_STATE_CHECK_BATCH_SIZE", "17") t.Setenv("GENERATION_TASK_STATE_CHECK_LOOKBACK", "6h") configPath := writeTestConfig(t, ` generation: {} `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Generation.TaskStateCheckInterval != 3*time.Minute || cfg.Generation.TaskStateCheckTimeout != 4*time.Second || cfg.Generation.TaskStateCheckBatchSize != 17 || cfg.Generation.TaskStateCheckLookback != 6*time.Hour { t.Fatalf("unexpected generation state check env config: %#v", cfg.Generation) } } func TestLoadAppliesBrowserFetchDefaultsAndOverrides(t *testing.T) { configPath := writeTestConfig(t, ` browser_fetch: enabled: true service_url: " http://browser-fetch:8082/ " token: " token " allowed_domains: - " zhuanlan.zhihu.com " - "" trigger_status: - 403 - 403 - 500 `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if !cfg.BrowserFetch.Enabled { t.Fatalf("expected browser fetch enabled") } if cfg.BrowserFetch.Provider != "lightpanda" { t.Fatalf("expected default provider lightpanda, got %q", cfg.BrowserFetch.Provider) } if cfg.BrowserFetch.ServiceURL != "http://browser-fetch:8082" { t.Fatalf("expected normalized service url, got %q", cfg.BrowserFetch.ServiceURL) } if cfg.BrowserFetch.Token != "token" { t.Fatalf("expected normalized token, got %q", cfg.BrowserFetch.Token) } if cfg.BrowserFetch.QueueSize != 64 || cfg.BrowserFetch.WorkerConcurrency != 1 || cfg.BrowserFetch.RequestTimeout != 45*time.Second || cfg.BrowserFetch.CacheTTL != 6*time.Hour { t.Fatalf("unexpected browser fetch defaults: %#v", cfg.BrowserFetch) } if len(cfg.BrowserFetch.AllowedDomains) != 1 || cfg.BrowserFetch.AllowedDomains[0] != "zhuanlan.zhihu.com" { t.Fatalf("unexpected allowed domains: %#v", cfg.BrowserFetch.AllowedDomains) } if len(cfg.BrowserFetch.TriggerStatus) != 2 || cfg.BrowserFetch.TriggerStatus[0] != 403 || cfg.BrowserFetch.TriggerStatus[1] != 500 { t.Fatalf("unexpected trigger status: %#v", cfg.BrowserFetch.TriggerStatus) } } func TestLoadAppliesBrowserFetchEnvOverrides(t *testing.T) { t.Setenv("BROWSER_FETCH_ENABLED", "true") t.Setenv("BROWSER_FETCH_PROVIDER", "lightpanda_service") t.Setenv("BROWSER_FETCH_SERVICE_URL", "http://env-browser-fetch:8082/") t.Setenv("BROWSER_FETCH_TOKEN", "env-token") t.Setenv("BROWSER_FETCH_QUEUE_SIZE", "11") t.Setenv("BROWSER_FETCH_WORKER_CONCURRENCY", "3") t.Setenv("BROWSER_FETCH_REQUEST_TIMEOUT", "12s") t.Setenv("BROWSER_FETCH_CACHE_TTL", "30m") t.Setenv("BROWSER_FETCH_ALLOWED_DOMAINS", "zhihu.com,example.com") t.Setenv("BROWSER_FETCH_TRIGGER_STATUS", "403,429,broken,503") configPath := writeTestConfig(t, ` browser_fetch: {} `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error = %v", err) } if !cfg.BrowserFetch.Enabled || cfg.BrowserFetch.Provider != "lightpanda_service" || cfg.BrowserFetch.ServiceURL != "http://env-browser-fetch:8082" || cfg.BrowserFetch.Token != "env-token" || cfg.BrowserFetch.QueueSize != 11 || cfg.BrowserFetch.WorkerConcurrency != 3 || cfg.BrowserFetch.RequestTimeout != 12*time.Second || cfg.BrowserFetch.CacheTTL != 30*time.Minute { t.Fatalf("unexpected browser fetch env config: %#v", cfg.BrowserFetch) } if got := cfg.BrowserFetch.AllowedDomains; len(got) != 2 || got[0] != "zhihu.com" || got[1] != "example.com" { t.Fatalf("unexpected env allowed domains: %#v", got) } if got := cfg.BrowserFetch.TriggerStatus; len(got) != 3 || got[0] != 403 || got[1] != 429 || got[2] != 503 { t.Fatalf("unexpected env trigger status: %#v", got) } } func TestStoreReloadsConfigAndKeepsPreviousOnInvalidYAML(t *testing.T) { configPath := writeTestConfig(t, ` jwt: secret: first access_ttl: 15m refresh_ttl: 720h `) store, err := NewStore(configPath) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() events := make(chan ReloadEvent, 4) go func() { _ = store.Watch(ctx, nil, func(event ReloadEvent) { events <- event }) }() waitForStoreWatch(t, store) time.Sleep(100 * time.Millisecond) writeFile(t, configPath, ` jwt: secret: second access_ttl: 20m refresh_ttl: 720h `) event := waitForReloadEvent(t, events) if event.Current.JWT.Secret != "second" { t.Fatalf("expected reloaded jwt secret second, got %q", event.Current.JWT.Secret) } if store.Current().JWT.AccessTTL != 20*time.Minute { t.Fatalf("expected store access ttl 20m, got %s", store.Current().JWT.AccessTTL) } writeFile(t, configPath, "jwt:\n secret: [broken\n") time.Sleep(300 * time.Millisecond) if store.Current().JWT.Secret != "second" { t.Fatalf("expected invalid yaml to keep previous config, got %q", store.Current().JWT.Secret) } } func TestStoreReloadsWhenLocalOverrideIsCreated(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.yaml") localPath := filepath.Join(dir, "config.local.yaml") writeFile(t, configPath, ` generation: stream_enabled: false `) store, err := NewStore(configPath) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() events := make(chan ReloadEvent, 4) go func() { _ = store.Watch(ctx, nil, func(event ReloadEvent) { events <- event }) }() waitForStoreWatch(t, store) time.Sleep(100 * time.Millisecond) writeFile(t, localPath, ` generation: stream_enabled: true `) event := waitForReloadEvent(t, events) if !event.Current.Generation.StreamEnabled { t.Fatalf("expected created local override to enable stream") } } func writeTestConfig(t *testing.T, body string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.yaml") writeFile(t, path, body) return path } func writeFile(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write file %s: %v", path, err) } } func waitForStoreWatch(t *testing.T, store *Store) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if store.runtime != nil && store.runtime.Value("").Load() != nil { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("config store watcher did not start") } func waitForReloadEvent(t *testing.T, events <-chan ReloadEvent) ReloadEvent { t.Helper() timeout := time.After(3 * time.Second) for { select { case event := <-events: if event.Current != nil { return event } case <-timeout: t.Fatalf("timed out waiting for reload event") } } }