package config import ( "os" "path/filepath" "testing" "time" ) // validConfig returns a minimal valid YAML configuration. func validConfig() string { return ` address: ":9090" 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.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.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: url: "" 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.ShutdownTimeout != "30s" { t.Errorf( "ShutdownTimeout = %q, want %q", cfg.ShutdownTimeout, "30s", ) } } func TestApplyDefaults_NoOverwrite(t *testing.T) { cfg := &Config{ Address: ":9999", ShutdownTimeout: "120s", } cfg.ApplyDefaults() if cfg.Address != ":9999" { t.Errorf("Address = %q, want %q", cfg.Address, ":9999") } if cfg.ShutdownTimeout != "120s" { t.Errorf( "ShutdownTimeout = %q, want %q", cfg.ShutdownTimeout, "120s", ) } } func TestLoad_Jobs(t *testing.T) { tests := []struct { name string config string shouldErr bool check func(t *testing.T, cfg *Config) }{ { name: "valid job config", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart jobs: - name: daily_summary schedule: "0 9 * * *" enabled: true system_message: "You are a research assistant." task: "Summarize today's news about AI." timeout: "10m" max_iterations: 20 `, check: func(t *testing.T, cfg *Config) { if len(cfg.Jobs) != 1 { t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs)) } j := cfg.Jobs[0] if j.Name != "daily_summary" { t.Errorf("Name = %q, want %q", j.Name, "daily_summary") } if j.Task != "Summarize today's news about AI." { t.Errorf("Task = %q, want %q", j.Task, "Summarize today's news about AI.") } if j.Schedule != "0 9 * * *" { t.Errorf("Schedule = %q, want %q", j.Schedule, "0 9 * * *") } timeout, err := time.ParseDuration(j.Timeout) if err != nil { t.Fatalf("ParseDuration(%q) error = %v", j.Timeout, err) } if timeout.Minutes() != 10 { t.Errorf("Timeout = %v, want 10m", timeout) } if j.MaxIterations != 20 { t.Errorf("MaxIterations = %d, want 20", j.MaxIterations) } if !j.IsEnabled() { t.Error("IsEnabled() = false, want true") } }, }, { name: "job with minimal config (defaults)", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart jobs: - name: log_job schedule: "*/5 * * * *" task: "Check system health." `, check: func(t *testing.T, cfg *Config) { if len(cfg.Jobs) != 1 { t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs)) } j := cfg.Jobs[0] if j.MaxIterations != 0 { t.Errorf("MaxIterations = %d, want 0 (unlimited)", j.MaxIterations) } }, }, { name: "disabled job", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart jobs: - name: disabled_job schedule: "0 0 * * *" enabled: false task: "This should not run." `, check: func(t *testing.T, cfg *Config) { if len(cfg.Jobs) != 1 { t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs)) } if cfg.Jobs[0].IsEnabled() { t.Error("IsEnabled() = true, want false") } }, }, { name: "job missing name", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart jobs: - schedule: "0 9 * * *" task: "do something" `, shouldErr: true, }, { name: "job missing task", config: ` llm: model: test-model url: http://localhost:8080 stt: url: http://localhost:8178 tts: url: http://localhost:8880 voice: af_heart jobs: - name: no_task schedule: "0 9 * * *" `, shouldErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.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) } }) } }