package config import ( "os" "path/filepath" "sync" "sync/atomic" "testing" "time" ) func writeConfig(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } } func TestLoadDirectoryAndDurations(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") writeConfig(t, path, "server:\n http:\n addr: :9000\n timeout: 600s\ndata:\n redis:\n read_timeout: 200ms\nadmin:\n jwt:\n expires_time: 168h\n media:\n session_ttl: 36\n") loaded, err := Load(dir) if err != nil { t.Fatal(err) } if loaded.Server == nil || loaded.Server.HTTP == nil || loaded.Server.HTTP.Timeout != 10*time.Minute { t.Fatalf("server timeout = %#v", loaded.Server) } if loaded.Data == nil || loaded.Data.Redis == nil || loaded.Data.Redis.ReadTimeout != 200*time.Millisecond { t.Fatalf("redis timeout = %#v", loaded.Data) } if loaded.Admin == nil || loaded.Admin.JWT == nil || loaded.Admin.JWT.ExpiresTime != 7*24*time.Hour { t.Fatalf("jwt duration = %#v", loaded.Admin) } if loaded.Admin.ConfigPath != path { t.Fatalf("config path = %q, want %q", loaded.Admin.ConfigPath, path) } } func TestLoadRejectsInvalidDuration(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") writeConfig(t, path, "server:\n http:\n timeout: definitely-not-a-duration\n") if _, err := Load(path); err == nil { t.Fatal("invalid duration was accepted") } } func TestLoadEnvironmentOverride(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") writeConfig(t, path, "server:\n http:\n addr: :8000\n") t.Setenv("KRA_SERVER_HTTP_ADDR", ":9100") loaded, err := Load(path) if err != nil { t.Fatal(err) } if loaded.Server == nil || loaded.Server.HTTP == nil || loaded.Server.HTTP.Addr != ":9100" { t.Fatalf("environment override = %#v", loaded.Server) } } func TestStoreSnapshotIsolationAndSubscriptions(t *testing.T) { store := NewStore(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "before"}}}) var calls atomic.Int32 stop := store.Subscribe(func(value *Config) { calls.Add(1) value.Admin.JWT.SigningKey = "callback-mutation" }) store.Replace(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "after"}}}) stop() stop() value := store.Snapshot() if value == nil || value.Admin == nil || value.Admin.JWT == nil || value.Admin.JWT.SigningKey != "after" { t.Fatalf("snapshot was mutated by callback: %#v", value) } if calls.Load() != 1 { t.Fatalf("callback count = %d", calls.Load()) } } func TestStoreConcurrentReadersAndReplacements(t *testing.T) { store := NewStore(&Config{Data: &Data{Database: &Database{Name: "initial"}}}) var wait sync.WaitGroup for i := 0; i < 8; i++ { wait.Add(1) go func() { defer wait.Done() for j := 0; j < 500; j++ { _ = store.Snapshot() } }() } for i := 0; i < 500; i++ { store.Replace(&Config{Data: &Data{Database: &Database{Name: "next"}}}) } wait.Wait() } func TestStoreWatchReloadAndClose(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") writeConfig(t, path, "admin:\n app:\n app_id: first\n") store, err := LoadStore(path) if err != nil { t.Fatal(err) } defer store.Close() writeConfig(t, path, "admin:\n app:\n app_id: second\n") deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { value := store.Snapshot() if value != nil && value.Admin != nil && value.Admin.App != nil && value.Admin.App.AppID == "second" { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("watcher did not reload configuration: %#v", store.Snapshot()) } func TestStoreWatchPreservesDatabaseBackedIntegrationSettings(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") writeConfig(t, path, "data: {}\nadmin: {}\n") store, err := LoadStore(path) if err != nil { t.Fatal(err) } defer store.Close() current := store.Snapshot() current.Admin.Storage = &Storage{Type: "local"} current.Admin.Email = &Email{Host: "smtp.example.com"} store.Replace(current) writeConfig(t, path, "data:\n redis:\n addr: 127.0.0.1:6379\nadmin: {}\n") deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { value := store.Snapshot() if value != nil && value.Data != nil && value.Data.Redis != nil && value.Data.Redis.Addr != "" { if value.Admin == nil || value.Admin.Storage == nil || value.Admin.Storage.Type != "local" || value.Admin.Email == nil || value.Admin.Email.Host != "smtp.example.com" { t.Fatalf("database-backed settings were lost: %#v", value.Admin) } return } time.Sleep(20 * time.Millisecond) } t.Fatal("watcher did not publish the changed file") } func TestMergeRuntimeConfigPreservesOmittedSections(t *testing.T) { current := &Config{ Server: &Server{HTTP: &ServerHTTP{Addr: ":8080"}}, Data: &Data{Database: &Database{Source: "current-dsn"}}, Admin: &Admin{ConfigPath: "config.yaml", Storage: &Storage{Type: "local"}, Email: &Email{Host: "smtp.example.com"}}, } next := &Config{Admin: &Admin{App: &App{AppID: "next"}}} merged := MergeRuntimeConfig(current, next) if merged.Server == nil || merged.Server.HTTP == nil || merged.Server.HTTP.Addr != ":8080" { t.Fatalf("server was not preserved: %#v", merged.Server) } if merged.Data == nil || merged.Data.Database == nil || merged.Data.Database.Source != "current-dsn" { t.Fatalf("data was not preserved: %#v", merged.Data) } if merged.Admin.Storage == nil || merged.Admin.Storage.Type != "local" || merged.Admin.Email == nil || merged.Admin.Email.Host != "smtp.example.com" { t.Fatalf("integration settings were not preserved: %#v", merged.Admin) } merged.Admin.Storage.Type = "changed" if current.Admin.Storage.Type != "local" { t.Fatal("MergeRuntimeConfig leaked mutable storage state into current config") } }