all repos — llm_aggregator @ 715e67108f4989876184fa73e7dcadecc63d59af

A CLI tool to aggregate RSS feeds and summarise them with LLMs

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}