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// TestIsZero tests the isZero helper function with various types including pointers.
211func TestIsZero(t *testing.T) {
212 tests := []struct {
213 name string
214 value any
215 expected bool
216 }{
217 // String tests
218 {"empty string is zero", "", true},
219 {"non-empty string is not zero", "hello", false},
220
221 // Int tests
222 {"zero int is zero", 0, true},
223 {"non-zero int is not zero", 42, false},
224
225 // Float tests
226 {"zero float is zero", 0.0, true},
227 {"non-zero float is not zero", 0.7, false},
228
229 // Bool tests
230 {"false bool is zero", false, true},
231 {"true bool is not zero", true, false},
232
233 // Pointer tests - the bug we fixed
234 {"nil *string is zero", (*string)(nil), true},
235 {"non-nil *string is not zero", strPtr("hello"), false},
236 {"nil *int is zero", (*int)(nil), true},
237 {"non-nil *int is not zero", intPtr(42), false},
238 {"nil *float64 is zero", (*float64)(nil), true},
239 {"non-nil *float64 is not zero", floatPtr(0.7), false},
240 {"nil *bool is zero", (*bool)(nil), true},
241 {"non-nil *bool is not zero", boolPtr(true), false},
242
243 // Unknown type
244 {"nil interface is zero", nil, true},
245 }
246
247 for _, tt := range tests {
248 t.Run(tt.name, func(t *testing.T) {
249 result := isZero(tt.value)
250 if result != tt.expected {
251 t.Errorf("isZero(%v) = %v, want %v", tt.value, result, tt.expected)
252 }
253 })
254 }
255}
256
257// TestViperToRuntimePrecedence tests that ViperToRuntime correctly respects precedence.
258// Order from highest to lowest: CLI args > Environment variables > Config file > Defaults
259func TestViperToRuntimePrecedence(t *testing.T) {
260 // Create temporary directory for test
261 tempDir := t.TempDir()
262 oldEnv := os.Getenv("XDG_CONFIG_HOME")
263 os.Setenv("XDG_CONFIG_HOME", tempDir)
264 defer func() {
265 os.Setenv("XDG_CONFIG_HOME", oldEnv)
266 }()
267
268 // Save a config file with known values
269 configuredModel := "config-file-model"
270 configuredBaseURL := "https://config-file.example.com"
271 configuredTemperature := 0.3
272
273 cfg := DefaultConfig()
274 cfg.Model = configuredModel
275 cfg.BaseURL = configuredBaseURL
276 cfg.Temperature = configuredTemperature
277 cfg.MaxTokens = 5000
278
279 if err := cfg.Save(); err != nil {
280 t.Fatalf("Failed to save config: %v", err)
281 }
282
283 // Test 1: Config file should override defaults
284 t.Run("config file overrides defaults", func(t *testing.T) {
285 // Clear any env vars
286 os.Unsetenv("LLM_AGGREGATOR_MODEL")
287 os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
288 os.Unsetenv("LLM_AGGREGATOR_TEMPERATURE")
289 os.Unsetenv("LLM_AGGREGATOR_MAX_TOKENS")
290
291 v := GetViper()
292 rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
293
294 if rt.Model != configuredModel {
295 t.Errorf("Model = %q, want %q (config file should override default)", rt.Model, configuredModel)
296 }
297 if rt.BaseURL != configuredBaseURL {
298 t.Errorf("BaseURL = %q, want %q", rt.BaseURL, configuredBaseURL)
299 }
300 })
301
302 // Test 2: Environment variables should override config file
303 t.Run("environment variables override config file", func(t *testing.T) {
304 envModel := "env-override-model"
305 envBaseURL := "https://env-override.example.com"
306 os.Setenv("LLM_AGGREGATOR_MODEL", envModel)
307 os.Setenv("LLM_AGGREGATOR_BASE_URL", envBaseURL)
308 defer func() {
309 os.Unsetenv("LLM_AGGREGATOR_MODEL")
310 os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
311 }()
312
313 v := GetViper()
314 rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
315
316 if rt.Model != envModel {
317 t.Errorf("Model = %q, want %q (env var should override config file)", rt.Model, envModel)
318 }
319 if rt.BaseURL != envBaseURL {
320 t.Errorf("BaseURL = %q, want %q", rt.BaseURL, envBaseURL)
321 }
322 })
323
324 // Test 3: CLI args (via BindCLIArgs) should override env vars and config file
325 t.Run("CLI args override environment variables and config file", func(t *testing.T) {
326 // Create a fresh viper instance to avoid polluting global state
327 v := viper.New()
328 v.SetDefault("model", "default-model")
329 v.SetDefault("base_url", "https://default.example.com")
330 v.SetDefault("temperature", 0.7)
331
332 // Set env vars that should be overridden
333 os.Setenv("LLM_AGGREGATOR_MODEL", "env-should-be-overridden")
334 os.Setenv("LLM_AGGREGATOR_BASE_URL", "https://env-should-be-overridden.example.com")
335 defer func() {
336 os.Unsetenv("LLM_AGGREGATOR_MODEL")
337 os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
338 }()
339
340 // Simulate CLI args binding - using pointer types like the actual Args struct
341 cliModel := "cli-override-model"
342 cliBaseURL := "https://cli-override.example.com"
343 cliTemperature := 0.9
344
345 cliArgs := map[string]any{
346 "model": &cliModel,
347 "base_url": &cliBaseURL,
348 "temperature": &cliTemperature,
349 }
350 BindCLIArgs(v, cliArgs)
351
352 if v.GetString("model") != cliModel {
353 t.Errorf("model = %q, want %q (CLI should override env var)", v.GetString("model"), cliModel)
354 }
355 if v.GetString("base_url") != cliBaseURL {
356 t.Errorf("base_url = %q, want %q", v.GetString("base_url"), cliBaseURL)
357 }
358 if v.GetFloat64("temperature") != cliTemperature {
359 t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), cliTemperature)
360 }
361 })
362
363 // Test 4: Nil/unset CLI args should NOT override existing values
364 // Note: This test verifies that when a CLI arg is nil (not provided), the existing
365 // value in viper is preserved. However, since viper doesn't support unsetting keys,
366 // and the test uses a fresh viper without BindEnv, we can't properly test the
367 // env var persistence here. This is actually a test design limitation.
368 t.Run("nil CLI args do not override viper values", func(t *testing.T) {
369 // Create a fresh viper instance to avoid polluting global state
370 v := viper.New()
371 v.SetDefault("model", "default-model")
372
373 // Simulate CLI args where some values were not provided (nil pointers)
374 var nilModel *string = nil
375 cliArgs := map[string]any{
376 "model": nilModel, // Not provided on CLI
377 }
378 BindCLIArgs(v, cliArgs)
379
380 // Since we didn't call BindEnv or Set any value, model should keep its default
381 if v.GetString("model") != "default-model" {
382 t.Errorf("model = %q, want %q (nil CLI arg should preserve viper default)", v.GetString("model"), "default-model")
383 }
384 })
385
386 // Test 5: Empty string CLI args should NOT override existing values
387 t.Run("empty string CLI args do not override viper values", func(t *testing.T) {
388 // Create a fresh viper instance to avoid polluting global state
389 v := viper.New()
390 v.SetDefault("model", "default-model")
391
392 // Simulate CLI args where value was provided as empty string
393 cliArgs := map[string]any{
394 "model": "", // Empty string should not override
395 }
396 BindCLIArgs(v, cliArgs)
397
398 // Empty string is a "zero" value for string type, so it should not override
399 if v.GetString("model") != "default-model" {
400 t.Errorf("model = %q, want %q (empty CLI arg should preserve viper default)", v.GetString("model"), "default-model")
401 }
402 })
403}
404
405// TestBindCLIArgsWithPointers tests BindCLIArgs specifically with pointer types.
406func TestBindCLIArgsWithPointers(t *testing.T) {
407 // Test that pointer types are handled correctly by isZero
408 t.Run("BindCLIArgs with pointer types", func(t *testing.T) {
409 v := viper.New()
410 v.SetDefault("model", "default-model")
411 v.SetDefault("max_tokens", 1000)
412 v.SetDefault("temperature", 0.7)
413
414 // Simulate CLI args with pointer types (as Args struct now uses)
415 model := "cli-model"
416 maxTokens := 2000
417 temperature := 0.5
418
419 args := map[string]any{
420 "model": &model,
421 "max_tokens": &maxTokens,
422 "temperature": &temperature,
423 }
424 BindCLIArgs(v, args)
425
426 if v.GetString("model") != "cli-model" {
427 t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
428 }
429 if v.GetInt("max_tokens") != 2000 {
430 t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
431 }
432 if v.GetFloat64("temperature") != 0.5 {
433 t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), 0.5)
434 }
435 })
436
437 t.Run("BindCLIArgs with nil pointers does not override", func(t *testing.T) {
438 v := viper.New()
439 v.SetDefault("model", "default-model")
440 v.SetDefault("max_tokens", 1000)
441
442 // Simulate CLI args where nothing was provided (all nil)
443 var nilStr *string = nil
444 var nilInt *int = nil
445
446 args := map[string]any{
447 "model": nilStr,
448 "max_tokens": nilInt,
449 }
450 BindCLIArgs(v, args)
451
452 // Should keep defaults
453 if v.GetString("model") != "default-model" {
454 t.Errorf("model = %q, want %q (nil should not override)", v.GetString("model"), "default-model")
455 }
456 if v.GetInt("max_tokens") != 1000 {
457 t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 1000)
458 }
459 })
460
461 t.Run("BindCLIArgs with zero int pointer DOES override (user explicitly passed --max-tokens 0)", func(t *testing.T) {
462 v := viper.New()
463 v.SetDefault("max_tokens", 1000)
464
465 zero := 0
466 args := map[string]any{
467 "max_tokens": &zero, // User explicitly passed --max-tokens 0, so 0 SHOULD override
468 }
469 BindCLIArgs(v, args)
470
471 if v.GetInt("max_tokens") != 0 {
472 t.Errorf("max_tokens = %d, want %d (explicit 0 should override default)", v.GetInt("max_tokens"), 0)
473 }
474 })
475
476 t.Run("BindCLIArgs with zero float64 pointer DOES override (user explicitly passed --temperature 0)", func(t *testing.T) {
477 v := viper.New()
478 v.SetDefault("temperature", 0.7)
479
480 zero := 0.0
481 args := map[string]any{
482 "temperature": &zero, // User explicitly passed --temperature 0, so 0 SHOULD override
483 }
484 BindCLIArgs(v, args)
485
486 if v.GetFloat64("temperature") != 0.0 {
487 t.Errorf("temperature = %f, want %f (explicit 0 should override default)", v.GetFloat64("temperature"), 0.0)
488 }
489 })
490}
491
492// Helper functions for creating pointers
493func strPtr(s string) *string { return &s }
494func intPtr(i int) *int { return &i }
495func floatPtr(f float64) *float64 { return &f }
496func boolPtr(b bool) *bool { return &b }
497
498// Helper function to check if string contains substring
499func contains(s, substr string) bool {
500 return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
501 (s[0:len(substr)] == substr || contains(s[1:], substr)))
502}
503
504// Helper function to check if path has suffix
505func hasSuffix(path, suffix string) bool {
506 if len(path) < len(suffix) {
507 return false
508 }
509 return path[len(path)-len(suffix):] == suffix
510}
511
512// TestConfigParsingAlwaysPasses ensures that parsing of options always passes.
513// This tests the integration between CLI args, config loading, and Viper.
514func TestConfigParsingAlwaysPasses(t *testing.T) {
515 // Create temporary directory for test
516 tempDir := t.TempDir()
517 oldEnv := os.Getenv("XDG_CONFIG_HOME")
518 os.Setenv("XDG_CONFIG_HOME", tempDir)
519 defer func() {
520 os.Setenv("XDG_CONFIG_HOME", oldEnv)
521 }()
522
523 // Create a test feeds file
524 feedsFile := filepath.Join(tempDir, "feeds.txt")
525 if err := os.WriteFile(feedsFile, []byte("https://example.com/feed.xml\nhttps://example.org/feed.xml"), 0644); err != nil {
526 t.Fatalf("Failed to create test feeds file: %v", err)
527 }
528
529 tests := []struct {
530 name string
531 cliArgs map[string]string
532 expectFeeds string
533 expectModel string
534 }{
535 {
536 name: "default configuration",
537 cliArgs: map[string]string{
538 "--feeds-file": feedsFile,
539 "--prompt": "Test prompt",
540 },
541 expectFeeds: feedsFile,
542 expectModel: "deepseek-chat",
543 },
544 {
545 name: "custom model via CLI",
546 cliArgs: map[string]string{
547 "--feeds-file": feedsFile,
548 "--prompt": "Test prompt",
549 "--model": "gpt-4",
550 },
551 expectFeeds: feedsFile,
552 expectModel: "gpt-4",
553 },
554 {
555 name: "custom api key via CLI",
556 cliArgs: map[string]string{
557 "--feeds-file": feedsFile,
558 "--prompt": "Test prompt",
559 "--api-key": "sk-test-key-12345",
560 },
561 expectFeeds: feedsFile,
562 expectModel: "deepseek-chat",
563 },
564 {
565 name: "all CLI options",
566 cliArgs: map[string]string{
567 "--feeds-file": feedsFile,
568 "--prompt": "Summarise tech news",
569 "--api-key": "sk-test-key",
570 "--model": "deepseek-coder",
571 "--max-articles-per-feed": "5",
572 "--max-days-old": "3",
573 "--max-total-articles": "15",
574 "--include-keywords": "ai,ml",
575 "--exclude-keywords": "advertisement",
576 "--max-tokens": "1000",
577 "--temperature": "0.3",
578 "--output": "json",
579 },
580 expectFeeds: feedsFile,
581 expectModel: "deepseek-coder",
582 },
583 }
584
585 for _, tt := range tests {
586 t.Run(tt.name, func(t *testing.T) {
587 // Build CLI args slice
588 args := []string{"llm_aggregator"}
589 for k, v := range tt.cliArgs {
590 // Remove leading dashes for go-arg compatibility
591 key := k
592 if strings.HasPrefix(key, "--") {
593 key = key[2:]
594 }
595 args = append(args, "--"+key, v)
596 }
597
598 // Save original os.Args
599 origArgs := os.Args
600 defer func() { os.Args = origArgs }()
601 os.Args = args
602
603 // Parse arguments - this should never fail for valid inputs
604 parsedArgs, err := cli.ParseArgs()
605 if err != nil {
606 t.Fatalf("Failed to parse CLI args: %v", err)
607 }
608
609 // Verify feeds file path is set correctly
610 if parsedArgs.FeedsFile != tt.expectFeeds {
611 t.Errorf("FeedsFile = %q, want %q", parsedArgs.FeedsFile, tt.expectFeeds)
612 }
613
614 // Verify prompt is set
615 if parsedArgs.Prompt == "" {
616 t.Error("Prompt should not be empty")
617 }
618
619 // Verify model matches expected
620 if parsedArgs.Model != nil && *parsedArgs.Model != tt.expectModel {
621 t.Errorf("Model = %q, want %q", *parsedArgs.Model, tt.expectModel)
622 }
623
624 // Verify API key if provided
625 if apiKey, ok := tt.cliArgs["--api-key"]; ok {
626 if parsedArgs.APIKey != nil && *parsedArgs.APIKey != apiKey {
627 t.Errorf("APIKey = %q, want %q", *parsedArgs.APIKey, apiKey)
628 }
629 }
630 })
631 }
632}
633
634// TestConfigLoadWithVariousSources tests that configuration loading works
635// regardless of the source (CLI, env, config file, defaults)
636func TestConfigLoadWithVariousSources(t *testing.T) {
637 // Create temporary directory for test
638 tempDir := t.TempDir()
639 oldEnv := os.Getenv("XDG_CONFIG_HOME")
640 os.Setenv("XDG_CONFIG_HOME", tempDir)
641 defer func() {
642 os.Setenv("XDG_CONFIG_HOME", oldEnv)
643 }()
644
645 // Test 1: Config loads with saved config file
646 t.Run("load with saved config file", func(t *testing.T) {
647 cfg := DefaultConfig()
648 cfg.Model = "saved-model"
649 cfg.MaxArticlesPerFeed = 30
650 cfg.Temperature = 0.9
651
652 if err := cfg.Save(); err != nil {
653 t.Fatalf("Save() failed: %v", err)
654 }
655
656 loadedCfg, err := Load()
657 if err != nil {
658 t.Fatalf("Load() failed: %v", err)
659 }
660
661 if loadedCfg.Model != "saved-model" {
662 t.Errorf("Model = %q, want %q", loadedCfg.Model, "saved-model")
663 }
664 if loadedCfg.MaxArticlesPerFeed != 30 {
665 t.Errorf("MaxArticlesPerFeed = %d, want 30", loadedCfg.MaxArticlesPerFeed)
666 }
667 if loadedCfg.Temperature != 0.9 {
668 t.Errorf("Temperature = %f, want 0.9", loadedCfg.Temperature)
669 }
670 })
671
672 // Test 2: Verify DefaultConfig returns correct defaults
673 t.Run("default config has correct values", func(t *testing.T) {
674 cfg := DefaultConfig()
675
676 if cfg.MaxArticlesPerFeed != defaults.DefaultMaxArticlesPerFeed {
677 t.Errorf("MaxArticlesPerFeed = %d, want %d", cfg.MaxArticlesPerFeed, defaults.DefaultMaxArticlesPerFeed)
678 }
679 if cfg.MaxDaysOld != defaults.DefaultMaxDaysOld {
680 t.Errorf("MaxDaysOld = %d, want %d", cfg.MaxDaysOld, defaults.DefaultMaxDaysOld)
681 }
682 if cfg.MaxTotalArticles != defaults.DefaultMaxTotalArticles {
683 t.Errorf("MaxTotalArticles = %d, want %d", cfg.MaxTotalArticles, defaults.DefaultMaxTotalArticles)
684 }
685 if cfg.BaseURL != defaults.DefaultBaseURL {
686 t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, defaults.DefaultBaseURL)
687 }
688 if cfg.Model != defaults.DefaultModel {
689 t.Errorf("Model = %q, want %q", cfg.Model, defaults.DefaultModel)
690 }
691 if cfg.MaxTokens != defaults.DefaultMaxTokens {
692 t.Errorf("MaxTokens = %d, want %d", cfg.MaxTokens, defaults.DefaultMaxTokens)
693 }
694 if cfg.Temperature != defaults.DefaultTemperature {
695 t.Errorf("Temperature = %f, want %f", cfg.Temperature, defaults.DefaultTemperature)
696 }
697 if cfg.Output != defaults.DefaultOutput {
698 t.Errorf("Output = %q, want %q", cfg.Output, defaults.DefaultOutput)
699 }
700 if cfg.IncludeArticles != defaults.DefaultIncludeArticles {
701 t.Errorf("IncludeArticles = %v, want %v", cfg.IncludeArticles, defaults.DefaultIncludeArticles)
702 }
703 })
704
705 // Test 3: Verify ConfigExists works correctly
706 t.Run("config exists check", func(t *testing.T) {
707 // Should not exist initially (new temp dir)
708 exists, err := ConfigExists()
709 if err != nil {
710 t.Fatalf("ConfigExists() failed: %v", err)
711 }
712
713 // Create a config
714 cfg := DefaultConfig()
715 if err := cfg.Save(); err != nil {
716 t.Fatalf("Save() failed: %v", err)
717 }
718
719 // Now should exist
720 exists, err = ConfigExists()
721 if err != nil {
722 t.Fatalf("ConfigExists() failed after save: %v", err)
723 }
724 if !exists {
725 t.Error("ConfigExists() should return true after saving")
726 }
727 })
728}
729
730// TestViperToConfigConversion tests the ViperToConfig conversion function.
731func TestViperToConfigConversion(t *testing.T) {
732 // Create a fresh viper instance
733 v := viper.New()
734 v.Set("max_articles_per_feed", 15)
735 v.Set("max_days_old", 14)
736 v.Set("max_total_articles", 50)
737 v.Set("include_keywords", "ai,ml")
738 v.Set("exclude_keywords", "spam")
739 v.Set("api_key", "sk-test-key")
740 v.Set("base_url", "https://api.example.com")
741 v.Set("model", "test-model")
742 v.Set("max_tokens", 3000)
743 v.Set("temperature", 0.5)
744 v.Set("system_prompt", "Test system prompt")
745 v.Set("output", "markdown")
746 v.Set("output_file", "/tmp/output.md")
747 v.Set("include_articles", true)
748
749 cfg := ViperToConfig(v)
750
751 if cfg.MaxArticlesPerFeed != 15 {
752 t.Errorf("MaxArticlesPerFeed = %d, want 15", cfg.MaxArticlesPerFeed)
753 }
754 if cfg.MaxDaysOld != 14 {
755 t.Errorf("MaxDaysOld = %d, want 14", cfg.MaxDaysOld)
756 }
757 if cfg.MaxTotalArticles != 50 {
758 t.Errorf("MaxTotalArticles = %d, want 50", cfg.MaxTotalArticles)
759 }
760 if cfg.IncludeKeywords != "ai,ml" {
761 t.Errorf("IncludeKeywords = %q, want %q", cfg.IncludeKeywords, "ai,ml")
762 }
763 if cfg.ExcludeKeywords != "spam" {
764 t.Errorf("ExcludeKeywords = %q, want %q", cfg.ExcludeKeywords, "spam")
765 }
766 if cfg.APIKey != "sk-test-key" {
767 t.Errorf("APIKey = %q, want %q", cfg.APIKey, "sk-test-key")
768 }
769 if cfg.BaseURL != "https://api.example.com" {
770 t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://api.example.com")
771 }
772 if cfg.Model != "test-model" {
773 t.Errorf("Model = %q, want %q", cfg.Model, "test-model")
774 }
775 if cfg.MaxTokens != 3000 {
776 t.Errorf("MaxTokens = %d, want 3000", cfg.MaxTokens)
777 }
778 if cfg.Temperature != 0.5 {
779 t.Errorf("Temperature = %f, want 0.5", cfg.Temperature)
780 }
781 if cfg.SystemPrompt != "Test system prompt" {
782 t.Errorf("SystemPrompt = %q, want %q", cfg.SystemPrompt, "Test system prompt")
783 }
784 if cfg.Output != "markdown" {
785 t.Errorf("Output = %q, want %q", cfg.Output, "markdown")
786 }
787 if cfg.OutputFile != "/tmp/output.md" {
788 t.Errorf("OutputFile = %q, want %q", cfg.OutputFile, "/tmp/output.md")
789 }
790 if cfg.IncludeArticles != true {
791 t.Errorf("IncludeArticles = %v, want true", cfg.IncludeArticles)
792 }
793}
794
795// TestBindCLIArgs tests the BindCLIArgs function.
796func TestBindCLIArgs(t *testing.T) {
797 // Create a fresh viper instance with defaults
798 v := viper.New()
799 v.SetDefault("model", "default-model")
800 v.SetDefault("max_tokens", 1000)
801
802 // Bind CLI args with override values
803 args := map[string]any{
804 "model": "cli-model",
805 "max_tokens": 2000,
806 }
807 BindCLIArgs(v, args)
808
809 if v.GetString("model") != "cli-model" {
810 t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
811 }
812 if v.GetInt("max_tokens") != 2000 {
813 t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
814 }
815
816 // Test that zero/empty values don't override
817 v2 := viper.New()
818 v2.SetDefault("model", "default-model")
819 v2.SetDefault("temperature", 0.7)
820
821 args2 := map[string]any{
822 "model": "", // Empty string should not override
823 "temperature": 0, // Zero should not override
824 }
825 BindCLIArgs(v2, args2)
826
827 if v2.GetString("model") != "default-model" {
828 t.Errorf("model = %q, want %q (empty should not override)", v2.GetString("model"), "default-model")
829 }
830 if v2.GetFloat64("temperature") != 0.7 {
831 t.Errorf("temperature = %f, want %f (zero should not override)", v2.GetFloat64("temperature"), 0.7)
832 }
833}