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 "--stdin": "",
387 "--plain": "",
388 },
389 expectFeeds: feedsFile,
390 expectModel: "deepseek-coder",
391 },
392 {
393 name: "stdin only",
394 cliArgs: map[string]string{
395 "--prompt": "Summarise from stdin",
396 "--stdin": "",
397 },
398 expectFeeds: "",
399 expectModel: "deepseek-chat",
400 },
401 }
402
403 for _, tt := range tests {
404 t.Run(tt.name, func(t *testing.T) {
405 // Build CLI args slice
406 args := []string{"llm_aggregator"}
407 for k, v := range tt.cliArgs {
408 // Remove leading dashes for go-arg compatibility
409 key := k
410 if strings.HasPrefix(key, "--") {
411 key = key[2:]
412 }
413 args = append(args, "--"+key)
414 if v != "" {
415 args = append(args, v)
416 }
417 }
418
419 // Save original os.Args
420 origArgs := os.Args
421 defer func() { os.Args = origArgs }()
422 os.Args = args
423
424 // Parse arguments - this should never fail for valid inputs
425 parsedArgs, err := cli.ParseArgs()
426 if err != nil {
427 t.Fatalf("Failed to parse CLI args: %v", err)
428 }
429
430 // Verify feeds file path is set correctly
431 if parsedArgs.FeedsFile != tt.expectFeeds {
432 t.Errorf("FeedsFile = %q, want %q", parsedArgs.FeedsFile, tt.expectFeeds)
433 }
434
435 // Verify prompt is set
436 if parsedArgs.Prompt == "" {
437 t.Error("Prompt should not be empty")
438 }
439
440 // Verify model matches expected
441 if parsedArgs.Model != nil && *parsedArgs.Model != tt.expectModel {
442 t.Errorf("Model = %q, want %q", *parsedArgs.Model, tt.expectModel)
443 }
444
445 // Verify API key if provided
446 if apiKey, ok := tt.cliArgs["--api-key"]; ok {
447 if parsedArgs.APIKey != nil && *parsedArgs.APIKey != apiKey {
448 t.Errorf("APIKey = %q, want %q", *parsedArgs.APIKey, apiKey)
449 }
450 }
451
452 // Verify plain flag
453 if tt.name == "all CLI options" && !parsedArgs.Plain {
454 t.Error("Plain should be true when --plain is provided")
455 }
456
457 // Verify stdin flag
458 if tt.name == "all CLI options" && !parsedArgs.Stdin {
459 t.Error("Stdin should be true when --stdin is provided")
460 }
461 if tt.name == "stdin only" && !parsedArgs.Stdin {
462 t.Error("Stdin should be true when --stdin is provided")
463 }
464 })
465 }
466}
467
468// TestBindCLIArgs tests the BindCLIArgs function.
469func TestBindCLIArgs(t *testing.T) {
470 // Create a fresh viper instance with defaults
471 v := viper.New()
472 v.SetDefault("model", "default-model")
473 v.SetDefault("max_tokens", 1000)
474
475 // Bind CLI args with override values
476 args := map[string]any{
477 "model": "cli-model",
478 "max_tokens": 2000,
479 }
480 BindCLIArgs(v, args)
481
482 if v.GetString("model") != "cli-model" {
483 t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
484 }
485 if v.GetInt("max_tokens") != 2000 {
486 t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
487 }
488
489 // Test that zero/empty values don't override
490 v2 := viper.New()
491 v2.SetDefault("model", "default-model")
492 v2.SetDefault("temperature", 0.7)
493
494 args2 := map[string]any{
495 "model": "", // Empty string should not override
496 "temperature": 0, // Zero should not override
497 }
498 BindCLIArgs(v2, args2)
499
500 if v2.GetString("model") != "default-model" {
501 t.Errorf("model = %q, want %q (empty should not override)", v2.GetString("model"), "default-model")
502 }
503 if v2.GetFloat64("temperature") != 0.7 {
504 t.Errorf("temperature = %f, want %f (zero should not override)", v2.GetFloat64("temperature"), 0.7)
505 }
506}