internal/config/config_test.go (view raw)
1package config
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "github.com/spf13/viper"
10
11 "llm_aggregator/internal/cli"
12 "llm_aggregator/internal/defaults"
13)
14
15func TestDefaultConfig(t *testing.T) {
16 cfg := DefaultConfig()
17
18 if cfg.MaxArticlesPerFeed != 10 {
19 t.Errorf("Expected MaxArticlesPerFeed = 10, got %d", cfg.MaxArticlesPerFeed)
20 }
21
22 if cfg.MaxDaysOld != 7 {
23 t.Errorf("Expected MaxDaysOld = 7, got %d", cfg.MaxDaysOld)
24 }
25
26 if cfg.MaxTotalArticles != 20 {
27 t.Errorf("Expected MaxTotalArticles = 20, got %d", cfg.MaxTotalArticles)
28 }
29
30 if cfg.Model != "deepseek-chat" {
31 t.Errorf("Expected Model = 'deepseek-chat', got %s", cfg.Model)
32 }
33
34 if cfg.MaxTokens != 4000 {
35 t.Errorf("Expected MaxTokens = 4000, got %d", cfg.MaxTokens)
36 }
37
38 if cfg.Temperature != 0.7 {
39 t.Errorf("Expected Temperature = 0.7, got %f", cfg.Temperature)
40 }
41
42 expectedPrompt := `You are an expert analyst and summariser.
43You analyse content from multiple sources and provide
44concise, insightful summaries based on user requests.
45Focus on key points, trends, and important information.`
46
47 if cfg.SystemPrompt != expectedPrompt {
48 t.Errorf("SystemPrompt doesn't match expected default")
49 }
50
51 if cfg.Output != "text" {
52 t.Errorf("Expected Output = 'text', got %s", cfg.Output)
53 }
54}
55
56func TestConfigPath(t *testing.T) {
57 path, err := GetConfigPath()
58 if err != nil {
59 t.Fatalf("Failed to get config path: %v", err)
60 }
61
62 // Should end with .config/llm_aggregator/config.toml
63 expectedSuffix := filepath.Join(".config", "llm_aggregator", "config.toml")
64 if !hasSuffix(path, expectedSuffix) {
65 t.Errorf("Config path %s doesn't end with %s", path, expectedSuffix)
66 }
67}
68
69func TestConfigSaveAndLoad(t *testing.T) {
70 // Create temporary directory for test
71 tempDir := t.TempDir()
72 oldEnv := os.Getenv("XDG_CONFIG_HOME")
73 os.Setenv("XDG_CONFIG_HOME", tempDir)
74 defer os.Setenv("XDG_CONFIG_HOME", oldEnv)
75
76 cfg := DefaultConfig()
77 cfg.SystemPrompt = "Test system prompt"
78 cfg.Model = "test-model"
79 cfg.MaxTokens = 1234
80
81 // Save config
82 if err := cfg.Save(); err != nil {
83 t.Fatalf("Failed to save config: %v", err)
84 }
85
86 // Verify file exists
87 configPath, err := GetConfigPath()
88 if err != nil {
89 t.Fatalf("Failed to get config path: %v", err)
90 }
91
92 if _, err := os.Stat(configPath); os.IsNotExist(err) {
93 t.Fatalf("Config file not created at %s", configPath)
94 }
95
96 // Load config
97 loadedCfg, err := Load()
98 if err != nil {
99 t.Fatalf("Failed to load config: %v", err)
100 }
101
102 if !loadedCfg.IsLoaded() {
103 t.Error("Config should be marked as loaded")
104 }
105
106 if loadedCfg.SystemPrompt != cfg.SystemPrompt {
107 t.Errorf("SystemPrompt mismatch: expected %q, got %q", cfg.SystemPrompt, loadedCfg.SystemPrompt)
108 }
109
110 if loadedCfg.Model != cfg.Model {
111 t.Errorf("Model mismatch: expected %s, got %s", cfg.Model, loadedCfg.Model)
112 }
113
114 if loadedCfg.MaxTokens != cfg.MaxTokens {
115 t.Errorf("MaxTokens mismatch: expected %d, got %d", cfg.MaxTokens, loadedCfg.MaxTokens)
116 }
117}
118
119func TestEnvironmentVariables(t *testing.T) {
120 // Create temporary directory for test
121 tempDir := t.TempDir()
122 oldEnv := os.Getenv("XDG_CONFIG_HOME")
123 os.Setenv("XDG_CONFIG_HOME", tempDir)
124 defer os.Setenv("XDG_CONFIG_HOME", oldEnv)
125
126 // Set environment variables
127 os.Setenv("LLM_AGGREGATOR_SYSTEM_PROMPT", "Env system prompt")
128 os.Setenv("LLM_AGGREGATOR_MODEL", "env-model")
129 os.Setenv("LLM_AGGREGATOR_MAX_TOKENS", "9999")
130 defer func() {
131 os.Unsetenv("LLM_AGGREGATOR_SYSTEM_PROMPT")
132 os.Unsetenv("LLM_AGGREGATOR_MODEL")
133 os.Unsetenv("LLM_AGGREGATOR_MAX_TOKENS")
134 }()
135
136 // Load config (should pick up env vars)
137 cfg, err := Load()
138 if err != nil {
139 t.Fatalf("Failed to load config: %v", err)
140 }
141
142 if cfg.SystemPrompt != "Env system prompt" {
143 t.Errorf("SystemPrompt should be from env var, got %q", cfg.SystemPrompt)
144 }
145
146 if cfg.Model != "env-model" {
147 t.Errorf("Model should be from env var, got %s", cfg.Model)
148 }
149
150 if cfg.MaxTokens != 9999 {
151 t.Errorf("MaxTokens should be from env var, got %d", cfg.MaxTokens)
152 }
153}
154
155func TestConfigExists(t *testing.T) {
156 // Create temporary directory for test
157 tempDir := t.TempDir()
158 oldEnv := os.Getenv("XDG_CONFIG_HOME")
159 os.Setenv("XDG_CONFIG_HOME", tempDir)
160 defer os.Setenv("XDG_CONFIG_HOME", oldEnv)
161
162 // Get config path to verify
163 configPath, err := GetConfigPath()
164 if err != nil {
165 t.Fatalf("Failed to get config path: %v", err)
166 }
167
168 // Ensure it's in our temp directory
169 if !contains(configPath, tempDir) {
170 t.Logf("Config path %s not in temp dir %s", configPath, tempDir)
171 }
172
173 // Initially should not exist
174 exists, err := ConfigExists()
175 if err != nil {
176 t.Fatalf("Failed to check config existence: %v", err)
177 }
178
179 if exists {
180 // Config exists, maybe from previous test run
181 t.Logf("Config exists at %s, removing...", configPath)
182 os.Remove(configPath)
183 // Check again
184 exists, err = ConfigExists()
185 if err != nil {
186 t.Fatalf("Failed to check config existence after removal: %v", err)
187 }
188 if exists {
189 t.Error("Config should not exist after removal")
190 }
191 }
192
193 // Create config
194 cfg := DefaultConfig()
195 if err := cfg.Save(); err != nil {
196 t.Fatalf("Failed to save config: %v", err)
197 }
198
199 // Now should exist
200 exists, err = ConfigExists()
201 if err != nil {
202 t.Fatalf("Failed to check config existence: %v", err)
203 }
204
205 if !exists {
206 t.Error("Config should exist after saving")
207 }
208}
209
210// Helper function to check if path has suffix
211func hasSuffix(path, suffix string) bool {
212 if len(path) < len(suffix) {
213 return false
214 }
215 return path[len(path)-len(suffix):] == suffix
216}
217
218// Helper function to check if string contains substring
219func contains(s, substr string) bool {
220 return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
221 (s[0:len(substr)] == substr || contains(s[1:], substr)))
222}
223
224// TestConfigParsingAlwaysPasses ensures that parsing of options always passes.
225// This tests the integration between CLI args, config loading, and Viper.
226func TestConfigParsingAlwaysPasses(t *testing.T) {
227 // Create temporary directory for test
228 tempDir := t.TempDir()
229 oldEnv := os.Getenv("XDG_CONFIG_HOME")
230 os.Setenv("XDG_CONFIG_HOME", tempDir)
231 defer func() {
232 os.Setenv("XDG_CONFIG_HOME", oldEnv)
233 }()
234
235 // Create a test feeds file
236 feedsFile := filepath.Join(tempDir, "feeds.txt")
237 if err := os.WriteFile(feedsFile, []byte("https://example.com/feed.xml\nhttps://example.org/feed.xml"), 0644); err != nil {
238 t.Fatalf("Failed to create test feeds file: %v", err)
239 }
240
241 tests := []struct {
242 name string
243 cliArgs map[string]string
244 expectFeeds string
245 expectModel string
246 }{
247 {
248 name: "default configuration",
249 cliArgs: map[string]string{
250 "--feeds-file": feedsFile,
251 "--prompt": "Test prompt",
252 },
253 expectFeeds: feedsFile,
254 expectModel: "deepseek-chat",
255 },
256 {
257 name: "custom model via CLI",
258 cliArgs: map[string]string{
259 "--feeds-file": feedsFile,
260 "--prompt": "Test prompt",
261 "--model": "gpt-4",
262 },
263 expectFeeds: feedsFile,
264 expectModel: "gpt-4",
265 },
266 {
267 name: "custom api key via CLI",
268 cliArgs: map[string]string{
269 "--feeds-file": feedsFile,
270 "--prompt": "Test prompt",
271 "--api-key": "sk-test-key-12345",
272 },
273 expectFeeds: feedsFile,
274 expectModel: "deepseek-chat",
275 },
276 {
277 name: "all CLI options",
278 cliArgs: map[string]string{
279 "--feeds-file": feedsFile,
280 "--prompt": "Summarise tech news",
281 "--api-key": "sk-test-key",
282 "--model": "deepseek-coder",
283 "--max-articles-per-feed": "5",
284 "--max-days-old": "3",
285 "--max-total-articles": "15",
286 "--include-keywords": "ai,ml",
287 "--exclude-keywords": "advertisement",
288 "--max-tokens": "1000",
289 "--temperature": "0.3",
290 "--output": "json",
291 },
292 expectFeeds: feedsFile,
293 expectModel: "deepseek-coder",
294 },
295 }
296
297 for _, tt := range tests {
298 t.Run(tt.name, func(t *testing.T) {
299 // Build CLI args slice
300 args := []string{"llm_aggregator"}
301 for k, v := range tt.cliArgs {
302 // Remove leading dashes for go-arg compatibility
303 key := k
304 if strings.HasPrefix(key, "--") {
305 key = key[2:]
306 }
307 args = append(args, "--"+key, v)
308 }
309
310 // Save original os.Args
311 origArgs := os.Args
312 defer func() { os.Args = origArgs }()
313 os.Args = args
314
315 // Parse arguments - this should never fail for valid inputs
316 parsedArgs, err := cli.ParseArgs()
317 if err != nil {
318 t.Fatalf("Failed to parse CLI args: %v", err)
319 }
320
321 // Verify feeds file path is set correctly
322 if parsedArgs.FeedsFile != tt.expectFeeds {
323 t.Errorf("FeedsFile = %q, want %q", parsedArgs.FeedsFile, tt.expectFeeds)
324 }
325
326 // Verify prompt is set
327 if parsedArgs.Prompt == "" {
328 t.Error("Prompt should not be empty")
329 }
330
331 // Verify model matches expected
332 if parsedArgs.Model != tt.expectModel {
333 t.Errorf("Model = %q, want %q", parsedArgs.Model, tt.expectModel)
334 }
335
336 // Verify API key if provided
337 if apiKey, ok := tt.cliArgs["--api-key"]; ok {
338 if parsedArgs.APIKey != apiKey {
339 t.Errorf("APIKey = %q, want %q", parsedArgs.APIKey, apiKey)
340 }
341 }
342 })
343 }
344}
345
346// TestConfigLoadWithVariousSources tests that configuration loading works
347// regardless of the source (CLI, env, config file, defaults)
348func TestConfigLoadWithVariousSources(t *testing.T) {
349 // Create temporary directory for test
350 tempDir := t.TempDir()
351 oldEnv := os.Getenv("XDG_CONFIG_HOME")
352 os.Setenv("XDG_CONFIG_HOME", tempDir)
353 defer func() {
354 os.Setenv("XDG_CONFIG_HOME", oldEnv)
355 }()
356
357 // Test 1: Config loads with saved config file
358 t.Run("load with saved config file", func(t *testing.T) {
359 cfg := DefaultConfig()
360 cfg.Model = "saved-model"
361 cfg.MaxArticlesPerFeed = 30
362 cfg.Temperature = 0.9
363
364 if err := cfg.Save(); err != nil {
365 t.Fatalf("Save() failed: %v", err)
366 }
367
368 loadedCfg, err := Load()
369 if err != nil {
370 t.Fatalf("Load() failed: %v", err)
371 }
372
373 if loadedCfg.Model != "saved-model" {
374 t.Errorf("Model = %q, want %q", loadedCfg.Model, "saved-model")
375 }
376 if loadedCfg.MaxArticlesPerFeed != 30 {
377 t.Errorf("MaxArticlesPerFeed = %d, want 30", loadedCfg.MaxArticlesPerFeed)
378 }
379 if loadedCfg.Temperature != 0.9 {
380 t.Errorf("Temperature = %f, want 0.9", loadedCfg.Temperature)
381 }
382 })
383
384 // Test 2: Verify DefaultConfig returns correct defaults
385 t.Run("default config has correct values", func(t *testing.T) {
386 cfg := DefaultConfig()
387
388 if cfg.MaxArticlesPerFeed != defaults.DefaultMaxArticlesPerFeed {
389 t.Errorf("MaxArticlesPerFeed = %d, want %d", cfg.MaxArticlesPerFeed, defaults.DefaultMaxArticlesPerFeed)
390 }
391 if cfg.MaxDaysOld != defaults.DefaultMaxDaysOld {
392 t.Errorf("MaxDaysOld = %d, want %d", cfg.MaxDaysOld, defaults.DefaultMaxDaysOld)
393 }
394 if cfg.MaxTotalArticles != defaults.DefaultMaxTotalArticles {
395 t.Errorf("MaxTotalArticles = %d, want %d", cfg.MaxTotalArticles, defaults.DefaultMaxTotalArticles)
396 }
397 if cfg.BaseURL != defaults.DefaultBaseURL {
398 t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, defaults.DefaultBaseURL)
399 }
400 if cfg.Model != defaults.DefaultModel {
401 t.Errorf("Model = %q, want %q", cfg.Model, defaults.DefaultModel)
402 }
403 if cfg.MaxTokens != defaults.DefaultMaxTokens {
404 t.Errorf("MaxTokens = %d, want %d", cfg.MaxTokens, defaults.DefaultMaxTokens)
405 }
406 if cfg.Temperature != defaults.DefaultTemperature {
407 t.Errorf("Temperature = %f, want %f", cfg.Temperature, defaults.DefaultTemperature)
408 }
409 if cfg.Output != defaults.DefaultOutput {
410 t.Errorf("Output = %q, want %q", cfg.Output, defaults.DefaultOutput)
411 }
412 if cfg.IncludeArticles != defaults.DefaultIncludeArticles {
413 t.Errorf("IncludeArticles = %v, want %v", cfg.IncludeArticles, defaults.DefaultIncludeArticles)
414 }
415 })
416
417 // Test 3: Verify ConfigExists works correctly
418 t.Run("config exists check", func(t *testing.T) {
419 // Should not exist initially (new temp dir)
420 exists, err := ConfigExists()
421 if err != nil {
422 t.Fatalf("ConfigExists() failed: %v", err)
423 }
424
425 // Create a config
426 cfg := DefaultConfig()
427 if err := cfg.Save(); err != nil {
428 t.Fatalf("Save() failed: %v", err)
429 }
430
431 // Now should exist
432 exists, err = ConfigExists()
433 if err != nil {
434 t.Fatalf("ConfigExists() failed after save: %v", err)
435 }
436 if !exists {
437 t.Error("ConfigExists() should return true after saving")
438 }
439 })
440}
441
442// TestViperToConfigConversion tests the ViperToConfig conversion function.
443func TestViperToConfigConversion(t *testing.T) {
444 // Create a fresh viper instance
445 v := viper.New()
446 v.Set("max_articles_per_feed", 15)
447 v.Set("max_days_old", 14)
448 v.Set("max_total_articles", 50)
449 v.Set("include_keywords", "ai,ml")
450 v.Set("exclude_keywords", "spam")
451 v.Set("api_key", "sk-test-key")
452 v.Set("base_url", "https://api.example.com")
453 v.Set("model", "test-model")
454 v.Set("max_tokens", 3000)
455 v.Set("temperature", 0.5)
456 v.Set("system_prompt", "Test system prompt")
457 v.Set("output", "markdown")
458 v.Set("output_file", "/tmp/output.md")
459 v.Set("include_articles", true)
460
461 cfg := ViperToConfig(v)
462
463 if cfg.MaxArticlesPerFeed != 15 {
464 t.Errorf("MaxArticlesPerFeed = %d, want 15", cfg.MaxArticlesPerFeed)
465 }
466 if cfg.MaxDaysOld != 14 {
467 t.Errorf("MaxDaysOld = %d, want 14", cfg.MaxDaysOld)
468 }
469 if cfg.MaxTotalArticles != 50 {
470 t.Errorf("MaxTotalArticles = %d, want 50", cfg.MaxTotalArticles)
471 }
472 if cfg.IncludeKeywords != "ai,ml" {
473 t.Errorf("IncludeKeywords = %q, want %q", cfg.IncludeKeywords, "ai,ml")
474 }
475 if cfg.ExcludeKeywords != "spam" {
476 t.Errorf("ExcludeKeywords = %q, want %q", cfg.ExcludeKeywords, "spam")
477 }
478 if cfg.APIKey != "sk-test-key" {
479 t.Errorf("APIKey = %q, want %q", cfg.APIKey, "sk-test-key")
480 }
481 if cfg.BaseURL != "https://api.example.com" {
482 t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://api.example.com")
483 }
484 if cfg.Model != "test-model" {
485 t.Errorf("Model = %q, want %q", cfg.Model, "test-model")
486 }
487 if cfg.MaxTokens != 3000 {
488 t.Errorf("MaxTokens = %d, want 3000", cfg.MaxTokens)
489 }
490 if cfg.Temperature != 0.5 {
491 t.Errorf("Temperature = %f, want 0.5", cfg.Temperature)
492 }
493 if cfg.SystemPrompt != "Test system prompt" {
494 t.Errorf("SystemPrompt = %q, want %q", cfg.SystemPrompt, "Test system prompt")
495 }
496 if cfg.Output != "markdown" {
497 t.Errorf("Output = %q, want %q", cfg.Output, "markdown")
498 }
499 if cfg.OutputFile != "/tmp/output.md" {
500 t.Errorf("OutputFile = %q, want %q", cfg.OutputFile, "/tmp/output.md")
501 }
502 if cfg.IncludeArticles != true {
503 t.Errorf("IncludeArticles = %v, want true", cfg.IncludeArticles)
504 }
505}
506
507// TestBindCLIArgs tests the BindCLIArgs function.
508func TestBindCLIArgs(t *testing.T) {
509 // Create a fresh viper instance with defaults
510 v := viper.New()
511 v.SetDefault("model", "default-model")
512 v.SetDefault("max_tokens", 1000)
513
514 // Bind CLI args with override values
515 args := map[string]any{
516 "model": "cli-model",
517 "max_tokens": 2000,
518 }
519 BindCLIArgs(v, args)
520
521 if v.GetString("model") != "cli-model" {
522 t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
523 }
524 if v.GetInt("max_tokens") != 2000 {
525 t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
526 }
527
528 // Test that zero/empty values don't override
529 v2 := viper.New()
530 v2.SetDefault("model", "default-model")
531 v2.SetDefault("temperature", 0.7)
532
533 args2 := map[string]any{
534 "model": "", // Empty string should not override
535 "temperature": 0, // Zero should not override
536 }
537 BindCLIArgs(v2, args2)
538
539 if v2.GetString("model") != "default-model" {
540 t.Errorf("model = %q, want %q (empty should not override)", v2.GetString("model"), "default-model")
541 }
542 if v2.GetFloat64("temperature") != 0.7 {
543 t.Errorf("temperature = %f, want %f (zero should not override)", v2.GetFloat64("temperature"), 0.7)
544 }
545}