all repos — llm_aggregator @ b7690ae41cc0c1076100d43a33ceb2aef502a2c0

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

internal/config/config_test.go (view raw)

  1package config
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9
 10	"github.com/spf13/viper"
 11
 12	"llm_aggregator/internal/cli"
 13)
 14
 15func TestIsZero(t *testing.T) {
 16	tests := []struct {
 17		name     string
 18		value    any
 19		expected bool
 20	}{
 21		// String tests
 22		{"empty string is zero", "", true},
 23		{"non-empty string is not zero", "hello", false},
 24
 25		// Int tests
 26		{"zero int is zero", 0, true},
 27		{"non-zero int is not zero", 42, false},
 28
 29		// Float tests
 30		{"zero float is zero", 0.0, true},
 31		{"non-zero float is not zero", 0.7, false},
 32
 33		// Bool tests
 34		{"false bool is zero", false, true},
 35		{"true bool is not zero", true, false},
 36
 37		// Pointer tests - the bug we fixed
 38		{"nil *string is zero", (*string)(nil), true},
 39		{"non-nil *string is not zero", strPtr("hello"), false},
 40		{"nil *int is zero", (*int)(nil), true},
 41		{"non-nil *int is not zero", intPtr(42), false},
 42		{"nil *float64 is zero", (*float64)(nil), true},
 43		{"non-nil *float64 is not zero", floatPtr(0.7), false},
 44		{"nil *bool is zero", (*bool)(nil), true},
 45		{"non-nil *bool is not zero", boolPtr(true), false},
 46
 47		// Unknown type
 48		{"nil interface is zero", nil, true},
 49	}
 50
 51	for _, tt := range tests {
 52		t.Run(tt.name, func(t *testing.T) {
 53			result := isZero(tt.value)
 54			if result != tt.expected {
 55				t.Errorf("isZero(%v) = %v, want %v", tt.value, result, tt.expected)
 56			}
 57		})
 58	}
 59}
 60
 61// TestViperToRuntimePrecedence tests that ViperToRuntime correctly respects precedence.
 62// Order from highest to lowest: CLI args > Environment variables > Config file > Defaults
 63func TestViperToRuntimePrecedence(t *testing.T) {
 64	// Create temporary directory for test
 65	tempDir := t.TempDir()
 66	oldEnv := os.Getenv("XDG_CONFIG_HOME")
 67	os.Setenv("XDG_CONFIG_HOME", tempDir)
 68	defer func() {
 69		os.Setenv("XDG_CONFIG_HOME", oldEnv)
 70	}()
 71
 72	// Save a config file with known values (manually write TOML since DefaultConfig/Save don't exist)
 73	configuredModel := "config-file-model"
 74	configuredBaseURL := "https://config-file.example.com"
 75	configuredTemperature := 0.3
 76	configuredMaxTokens := 5000
 77
 78	configContent := fmt.Sprintf(`model = "%s"
 79base_url = "%s"
 80temperature = %f
 81max_tokens = %d
 82`, configuredModel, configuredBaseURL, configuredTemperature, configuredMaxTokens)
 83
 84	configPath := filepath.Join(tempDir, "llm_aggregator", "config.toml")
 85	os.MkdirAll(filepath.Dir(configPath), 0755)
 86	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
 87		t.Fatalf("Failed to write config file: %v", err)
 88	}
 89
 90	// Test 1: Config file should override defaults
 91	t.Run("config file overrides defaults", func(t *testing.T) {
 92		// Clear any env vars
 93		os.Unsetenv("LLM_AGGREGATOR_MODEL")
 94		os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
 95		os.Unsetenv("LLM_AGGREGATOR_TEMPERATURE")
 96		os.Unsetenv("LLM_AGGREGATOR_MAX_TOKENS")
 97
 98		v := GetViper()
 99		rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
100
101		if rt.Model != configuredModel {
102			t.Errorf("Model = %q, want %q (config file should override default)", rt.Model, configuredModel)
103		}
104		if rt.BaseURL != configuredBaseURL {
105			t.Errorf("BaseURL = %q, want %q", rt.BaseURL, configuredBaseURL)
106		}
107	})
108
109	// Test 2: Environment variables should override config file
110	t.Run("environment variables override config file", func(t *testing.T) {
111		envModel := "env-override-model"
112		envBaseURL := "https://env-override.example.com"
113		os.Setenv("LLM_AGGREGATOR_MODEL", envModel)
114		os.Setenv("LLM_AGGREGATOR_BASE_URL", envBaseURL)
115		defer func() {
116			os.Unsetenv("LLM_AGGREGATOR_MODEL")
117			os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
118		}()
119
120		v := GetViper()
121		rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
122
123		if rt.Model != envModel {
124			t.Errorf("Model = %q, want %q (env var should override config file)", rt.Model, envModel)
125		}
126		if rt.BaseURL != envBaseURL {
127			t.Errorf("BaseURL = %q, want %q", rt.BaseURL, envBaseURL)
128		}
129	})
130
131	// Test 3: CLI args (via BindCLIArgs) should override env vars and config file
132	t.Run("CLI args override environment variables and config file", func(t *testing.T) {
133		// Create a fresh viper instance to avoid polluting global state
134		v := viper.New()
135		v.SetDefault("model", "default-model")
136		v.SetDefault("base_url", "https://default.example.com")
137		v.SetDefault("temperature", 0.7)
138
139		// Set env vars that should be overridden
140		os.Setenv("LLM_AGGREGATOR_MODEL", "env-should-be-overridden")
141		os.Setenv("LLM_AGGREGATOR_BASE_URL", "https://env-should-be-overridden.example.com")
142		defer func() {
143			os.Unsetenv("LLM_AGGREGATOR_MODEL")
144			os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
145		}()
146
147		// Simulate CLI args binding - using pointer types like the actual Args struct
148		cliModel := "cli-override-model"
149		cliBaseURL := "https://cli-override.example.com"
150		cliTemperature := 0.9
151
152		cliArgs := map[string]any{
153			"model":       &cliModel,
154			"base_url":    &cliBaseURL,
155			"temperature": &cliTemperature,
156		}
157		BindCLIArgs(v, cliArgs)
158
159		if v.GetString("model") != cliModel {
160			t.Errorf("model = %q, want %q (CLI should override env var)", v.GetString("model"), cliModel)
161		}
162		if v.GetString("base_url") != cliBaseURL {
163			t.Errorf("base_url = %q, want %q", v.GetString("base_url"), cliBaseURL)
164		}
165		if v.GetFloat64("temperature") != cliTemperature {
166			t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), cliTemperature)
167		}
168	})
169
170	// Test 4: Nil/unset CLI args should NOT override existing values
171	// Note: This test verifies that when a CLI arg is nil (not provided), the existing
172	// value in viper is preserved. However, since viper doesn't support unsetting keys,
173	// and the test uses a fresh viper without BindEnv, we can't properly test the
174	// env var persistence here. This is actually a test design limitation.
175	t.Run("nil CLI args do not override viper values", func(t *testing.T) {
176		// Create a fresh viper instance to avoid polluting global state
177		v := viper.New()
178		v.SetDefault("model", "default-model")
179
180		// Simulate CLI args where some values were not provided (nil pointers)
181		var nilModel *string = nil
182		cliArgs := map[string]any{
183			"model": nilModel, // Not provided on CLI
184		}
185		BindCLIArgs(v, cliArgs)
186
187		// Since we didn't call BindEnv or Set any value, model should keep its default
188		if v.GetString("model") != "default-model" {
189			t.Errorf("model = %q, want %q (nil CLI arg should preserve viper default)", v.GetString("model"), "default-model")
190		}
191	})
192
193	// Test 5: Empty string CLI args should NOT override existing values
194	t.Run("empty string CLI args do not override viper values", func(t *testing.T) {
195		// Create a fresh viper instance to avoid polluting global state
196		v := viper.New()
197		v.SetDefault("model", "default-model")
198
199		// Simulate CLI args where value was provided as empty string
200		cliArgs := map[string]any{
201			"model": "", // Empty string should not override
202		}
203		BindCLIArgs(v, cliArgs)
204
205		// Empty string is a "zero" value for string type, so it should not override
206		if v.GetString("model") != "default-model" {
207			t.Errorf("model = %q, want %q (empty CLI arg should preserve viper default)", v.GetString("model"), "default-model")
208		}
209	})
210}
211
212// TestBindCLIArgsWithPointers tests BindCLIArgs specifically with pointer types.
213func TestBindCLIArgsWithPointers(t *testing.T) {
214	// Test that pointer types are handled correctly by isZero
215	t.Run("BindCLIArgs with pointer types", func(t *testing.T) {
216		v := viper.New()
217		v.SetDefault("model", "default-model")
218		v.SetDefault("max_tokens", 1000)
219		v.SetDefault("temperature", 0.7)
220
221		// Simulate CLI args with pointer types (as Args struct now uses)
222		model := "cli-model"
223		maxTokens := 2000
224		temperature := 0.5
225
226		args := map[string]any{
227			"model":       &model,
228			"max_tokens":  &maxTokens,
229			"temperature": &temperature,
230		}
231		BindCLIArgs(v, args)
232
233		if v.GetString("model") != "cli-model" {
234			t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
235		}
236		if v.GetInt("max_tokens") != 2000 {
237			t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
238		}
239		if v.GetFloat64("temperature") != 0.5 {
240			t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), 0.5)
241		}
242	})
243
244	t.Run("BindCLIArgs with nil pointers does not override", func(t *testing.T) {
245		v := viper.New()
246		v.SetDefault("model", "default-model")
247		v.SetDefault("max_tokens", 1000)
248
249		// Simulate CLI args where nothing was provided (all nil)
250		var nilStr *string = nil
251		var nilInt *int = nil
252
253		args := map[string]any{
254			"model":      nilStr,
255			"max_tokens": nilInt,
256		}
257		BindCLIArgs(v, args)
258
259		// Should keep defaults
260		if v.GetString("model") != "default-model" {
261			t.Errorf("model = %q, want %q (nil should not override)", v.GetString("model"), "default-model")
262		}
263		if v.GetInt("max_tokens") != 1000 {
264			t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 1000)
265		}
266	})
267
268	t.Run("BindCLIArgs with zero int pointer DOES override (user explicitly passed --max-tokens 0)", func(t *testing.T) {
269		v := viper.New()
270		v.SetDefault("max_tokens", 1000)
271
272		zero := 0
273		args := map[string]any{
274			"max_tokens": &zero, // User explicitly passed --max-tokens 0, so 0 SHOULD override
275		}
276		BindCLIArgs(v, args)
277
278		if v.GetInt("max_tokens") != 0 {
279			t.Errorf("max_tokens = %d, want %d (explicit 0 should override default)", v.GetInt("max_tokens"), 0)
280		}
281	})
282
283	t.Run("BindCLIArgs with zero float64 pointer DOES override (user explicitly passed --temperature 0)", func(t *testing.T) {
284		v := viper.New()
285		v.SetDefault("temperature", 0.7)
286
287		zero := 0.0
288		args := map[string]any{
289			"temperature": &zero, // User explicitly passed --temperature 0, so 0 SHOULD override
290		}
291		BindCLIArgs(v, args)
292
293		if v.GetFloat64("temperature") != 0.0 {
294			t.Errorf("temperature = %f, want %f (explicit 0 should override default)", v.GetFloat64("temperature"), 0.0)
295		}
296	})
297}
298
299// Helper functions for creating pointers
300func strPtr(s string) *string { return &s }
301func intPtr(i int) *int { return &i }
302func floatPtr(f float64) *float64 { return &f }
303func boolPtr(b bool) *bool { return &b }
304
305// Helper function to check if string contains substring
306func contains(s, substr string) bool {
307	return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
308		(s[0:len(substr)] == substr || contains(s[1:], substr)))
309}
310
311// Helper function to check if path has suffix
312func hasSuffix(path, suffix string) bool {
313	if len(path) < len(suffix) {
314		return false
315	}
316	return path[len(path)-len(suffix):] == suffix
317}
318
319// TestConfigParsingAlwaysPasses ensures that parsing of options always passes.
320// This tests the integration between CLI args, config loading, and Viper.
321func TestConfigParsingAlwaysPasses(t *testing.T) {
322	// Create temporary directory for test
323	tempDir := t.TempDir()
324	oldEnv := os.Getenv("XDG_CONFIG_HOME")
325	os.Setenv("XDG_CONFIG_HOME", tempDir)
326	defer func() {
327		os.Setenv("XDG_CONFIG_HOME", oldEnv)
328	}()
329
330	// Create a test feeds file
331	feedsFile := tempDir + "/feeds.txt"
332	if err := os.WriteFile(feedsFile, []byte("https://example.com/feed.xml\nhttps://example.org/feed.xml"), 0644); err != nil {
333		t.Fatalf("Failed to create test feeds file: %v", err)
334	}
335
336	tests := []struct {
337		name        string
338		cliArgs     map[string]string
339		expectFeeds string
340		expectModel string
341	}{
342		{
343			name: "default configuration",
344			cliArgs: map[string]string{
345				"--feeds-file": feedsFile,
346				"--prompt":     "Test prompt",
347			},
348			expectFeeds: feedsFile,
349			expectModel: "deepseek-chat",
350		},
351		{
352			name: "custom model via CLI",
353			cliArgs: map[string]string{
354				"--feeds-file": feedsFile,
355				"--prompt":     "Test prompt",
356				"--model":      "gpt-4",
357			},
358			expectFeeds: feedsFile,
359			expectModel: "gpt-4",
360		},
361		{
362			name: "custom api key via CLI",
363			cliArgs: map[string]string{
364				"--feeds-file": feedsFile,
365				"--prompt":     "Test prompt",
366				"--api-key":    "sk-test-key-12345",
367			},
368			expectFeeds: feedsFile,
369			expectModel: "deepseek-chat",
370		},
371		{
372			name: "all CLI options",
373			cliArgs: map[string]string{
374				"--feeds-file":            feedsFile,
375				"--prompt":                "Summarise tech news",
376				"--api-key":               "sk-test-key",
377				"--model":                 "deepseek-coder",
378				"--max-articles-per-feed": "5",
379				"--max-days-old":          "3",
380				"--max-total-articles":    "15",
381				"--include-keywords":      "ai,ml",
382				"--exclude-keywords":     "advertisement",
383				"--max-tokens":            "1000",
384				"--temperature":          "0.3",
385				"--output":                "json",
386			},
387			expectFeeds: feedsFile,
388			expectModel: "deepseek-coder",
389		},
390	}
391
392	for _, tt := range tests {
393		t.Run(tt.name, func(t *testing.T) {
394			// Build CLI args slice
395			args := []string{"llm_aggregator"}
396			for k, v := range tt.cliArgs {
397				// Remove leading dashes for go-arg compatibility
398				key := k
399				if strings.HasPrefix(key, "--") {
400					key = key[2:]
401				}
402				args = append(args, "--"+key, v)
403			}
404
405			// Save original os.Args
406			origArgs := os.Args
407			defer func() { os.Args = origArgs }()
408			os.Args = args
409
410			// Parse arguments - this should never fail for valid inputs
411			parsedArgs, err := cli.ParseArgs()
412			if err != nil {
413				t.Fatalf("Failed to parse CLI args: %v", err)
414			}
415
416			// Verify feeds file path is set correctly
417			if parsedArgs.FeedsFile != tt.expectFeeds {
418				t.Errorf("FeedsFile = %q, want %q", parsedArgs.FeedsFile, tt.expectFeeds)
419			}
420
421			// Verify prompt is set
422			if parsedArgs.Prompt == "" {
423				t.Error("Prompt should not be empty")
424			}
425
426			// Verify model matches expected
427			if parsedArgs.Model != nil && *parsedArgs.Model != tt.expectModel {
428				t.Errorf("Model = %q, want %q", *parsedArgs.Model, tt.expectModel)
429			}
430
431			// Verify API key if provided
432			if apiKey, ok := tt.cliArgs["--api-key"]; ok {
433				if parsedArgs.APIKey != nil && *parsedArgs.APIKey != apiKey {
434					t.Errorf("APIKey = %q, want %q", *parsedArgs.APIKey, apiKey)
435				}
436			}
437		})
438	}
439}
440
441// TestBindCLIArgs tests the BindCLIArgs function.
442func TestBindCLIArgs(t *testing.T) {
443	// Create a fresh viper instance with defaults
444	v := viper.New()
445	v.SetDefault("model", "default-model")
446	v.SetDefault("max_tokens", 1000)
447
448	// Bind CLI args with override values
449	args := map[string]any{
450		"model":      "cli-model",
451		"max_tokens": 2000,
452	}
453	BindCLIArgs(v, args)
454
455	if v.GetString("model") != "cli-model" {
456		t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
457	}
458	if v.GetInt("max_tokens") != 2000 {
459		t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
460	}
461
462	// Test that zero/empty values don't override
463	v2 := viper.New()
464	v2.SetDefault("model", "default-model")
465	v2.SetDefault("temperature", 0.7)
466
467	args2 := map[string]any{
468		"model":      "", // Empty string should not override
469		"temperature": 0,  // Zero should not override
470	}
471	BindCLIArgs(v2, args2)
472
473	if v2.GetString("model") != "default-model" {
474		t.Errorf("model = %q, want %q (empty should not override)", v2.GetString("model"), "default-model")
475	}
476	if v2.GetFloat64("temperature") != 0.7 {
477		t.Errorf("temperature = %f, want %f (zero should not override)", v2.GetFloat64("temperature"), 0.7)
478	}
479}