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