package config import ( "os" "path/filepath" "testing" "time" ) // validConfig returns a minimal valid YAML configuration. func validConfig() string { return ` address: ":9090" concurrency: 2 shutdown_timeout: "60s" llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart ` } func writeConfig(t *testing.T, content string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.yaml") if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("write config file: %v", err) } return path } func TestLoad(t *testing.T) { tests := []struct { name string config string setup func(t *testing.T) shouldErr bool check func(t *testing.T, cfg *Config) }{ { name: "valid config", config: validConfig(), check: func(t *testing.T, cfg *Config) { if cfg.Address != ":9090" { t.Errorf( "Address = %q, want %q", cfg.Address, ":9090", ) } if cfg.Concurrency != 2 { t.Errorf( "Concurrency = %d, want 2", cfg.Concurrency, ) } if cfg.ShutdownTimeout != "60s" { t.Errorf( "ShutdownTimeout = %q, want %q", cfg.ShutdownTimeout, "60s", ) } if cfg.GetShutdownTimeout() != 60*time.Second { t.Errorf( "GetShutdownTimeout() = %v, want 60s", cfg.GetShutdownTimeout(), ) } }, }, { name: "defaults applied", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart `, check: func(t *testing.T, cfg *Config) { if cfg.Address != ":8080" { t.Errorf( "Address = %q, want %q", cfg.Address, ":8080", ) } if cfg.Concurrency != 1 { t.Errorf( "Concurrency = %d, want 1", cfg.Concurrency, ) } if cfg.ShutdownTimeout != "30s" { t.Errorf( "ShutdownTimeout = %q, want %q", cfg.ShutdownTimeout, "30s", ) } if cfg.GetShutdownTimeout() != 30*time.Second { t.Errorf( "GetShutdownTimeout() = %v, want 30s", cfg.GetShutdownTimeout(), ) } }, }, { name: "env expansion", config: ` llm: model: test-model url: http://localhost:8080 key: ${TEST_LLM_KEY} stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart `, setup: func(t *testing.T) { t.Setenv("TEST_LLM_KEY", "secret-api-key") }, check: func(t *testing.T, cfg *Config) { if cfg.LLM.Key != "secret-api-key" { t.Errorf( "LLM.Key = %q, want %q", cfg.LLM.Key, "secret-api-key", ) } }, }, { name: "env expansion unset var becomes empty", config: ` llm: model: test-model url: http://localhost:8080 key: ${TEST_UNSET_VAR} stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart `, check: func(t *testing.T, cfg *Config) { if cfg.LLM.Key != "" { t.Errorf( "LLM.Key = %q, want empty", cfg.LLM.Key, ) } }, }, { name: "invalid yaml", config: `llm: [invalid yaml`, shouldErr: true, }, { name: "invalid shutdown_timeout", config: ` shutdown_timeout: "not-a-duration" llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart `, shouldErr: true, }, { name: "missing llm model", config: ` llm: url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart `, shouldErr: true, }, { name: "missing llm url", config: ` llm: model: test-model stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart `, shouldErr: true, }, { name: "missing stt url", config: ` llm: model: test-model url: http://localhost:8080 stt: timeout: "30s" tts: url: http://localhost:8880 voice: af_heart `, shouldErr: true, }, { name: "missing tts url", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: voice: af_heart `, shouldErr: true, }, { name: "missing tts voice", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 `, shouldErr: true, }, { name: "config with tools", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart tools: - name: echo description: echoes input command: echo arguments: - "{{.message}}" `, check: func(t *testing.T, cfg *Config) { if len(cfg.Tools) != 1 { t.Errorf( "len(Tools) = %d, want 1", len(cfg.Tools), ) } if cfg.Tools[0].Name != "echo" { t.Errorf( "Tools[0].Name = %q, want %q", cfg.Tools[0].Name, "echo", ) } }, }, { name: "invalid tool", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart tools: - name: "" description: missing name command: echo `, shouldErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.setup != nil { tt.setup(t) } path := writeConfig(t, tt.config) cfg, err := Load(path) if tt.shouldErr { if err == nil { t.Fatal("Load() expected error") } return } if err != nil { t.Fatalf("Load() error = %v", err) } if tt.check != nil { tt.check(t, cfg) } }) } } func TestLoad_FileNotFound(t *testing.T) { if _, err := Load("/nonexistent/path/config.yaml"); err == nil { t.Fatal("Load() expected error for missing file") } } func TestApplyDefaults(t *testing.T) { cfg := &Config{} cfg.ApplyDefaults() if cfg.Address != ":8080" { t.Errorf("Address = %q, want %q", cfg.Address, ":8080") } if cfg.Concurrency != 1 { t.Errorf("Concurrency = %d, want 1", cfg.Concurrency) } if cfg.ShutdownTimeout != "30s" { t.Errorf( "ShutdownTimeout = %q, want %q", cfg.ShutdownTimeout, "30s", ) } } func TestApplyDefaults_NoOverwrite(t *testing.T) { cfg := &Config{ Address: ":9999", Concurrency: 5, ShutdownTimeout: "120s", } cfg.ApplyDefaults() if cfg.Address != ":9999" { t.Errorf("Address = %q, want %q", cfg.Address, ":9999") } if cfg.Concurrency != 5 { t.Errorf("Concurrency = %d, want 5", cfg.Concurrency) } if cfg.ShutdownTimeout != "120s" { t.Errorf( "ShutdownTimeout = %q, want %q", cfg.ShutdownTimeout, "120s", ) } }