internal/cli/args_test.go (view raw)
1package cli
2
3import (
4 "os"
5 "testing"
6)
7
8func TestFeedsFileParsing(t *testing.T) {
9 tests := []struct {
10 name string
11 feedsFile string
12 wantErr bool
13 setupFunc func()
14 cleanupFunc func()
15 }{
16 {
17 name: "valid feeds file path",
18 feedsFile: "/tmp/feeds.txt",
19 wantErr: false,
20 setupFunc: func() {
21 // Create a temporary feeds file
22 f, _ := os.Create("/tmp/feeds.txt")
23 f.Close()
24 },
25 cleanupFunc: func() {
26 os.Remove("/tmp/feeds.txt")
27 },
28 },
29 {
30 name: "empty feeds file path",
31 feedsFile: "",
32 wantErr: true, // Required field, should fail
33 },
34 {
35 name: "relative feeds file path",
36 feedsFile: "feeds/urls.txt",
37 wantErr: false, // Path doesn't need to exist for parsing
38 },
39 }
40
41 for _, tt := range tests {
42 t.Run(tt.name, func(t *testing.T) {
43 if tt.setupFunc != nil {
44 tt.setupFunc()
45 }
46 if tt.cleanupFunc != nil {
47 defer tt.cleanupFunc()
48 }
49
50 // Simulate command line args
51 args := []string{}
52 if tt.feedsFile != "" {
53 args = append(args, "--feeds-file", tt.feedsFile)
54 }
55 if tt.name != "empty feeds file path" || tt.feedsFile != "" {
56 args = append(args, "--prompt", "Test prompt")
57 }
58
59 if len(args) > 0 {
60 // Test parsing with feeds file
61 argsCopy := make([]string, len(os.Args))
62 copy(argsCopy, os.Args)
63 defer func() { os.Args = argsCopy }()
64
65 os.Args = append([]string{"llm_aggregator"}, args...)
66
67 parsedArgs, err := ParseArgs()
68 if tt.wantErr {
69 if err == nil {
70 t.Errorf("Expected error for feeds-file %q, got nil", tt.feedsFile)
71 }
72 } else {
73 if err != nil {
74 t.Errorf("Unexpected error for feeds-file %q: %v", tt.feedsFile, err)
75 } else if parsedArgs.FeedsFile != tt.feedsFile {
76 t.Errorf("FeedsFile mismatch: want %q, got %q", tt.feedsFile, parsedArgs.FeedsFile)
77 }
78 }
79 }
80 })
81 }
82}
83
84func TestPromptParsing(t *testing.T) {
85 tests := []struct {
86 name string
87 prompt string
88 wantErr bool
89 setupFunc func()
90 }{
91 {
92 name: "valid prompt text",
93 prompt: "What are the latest trends in free software?",
94 wantErr: false,
95 },
96 {
97 name: "short prompt",
98 prompt: "Hello",
99 wantErr: false,
100 },
101 {
102 name: "empty prompt",
103 prompt: "",
104 wantErr: true, // Required field
105 },
106 {
107 name: "multiline prompt",
108 prompt: "Summarise the following:\n1. News\n2. Updates\n3. Changes",
109 wantErr: false,
110 },
111 {
112 name: "prompt with special characters",
113 prompt: "What about AI & ML developments in 2024?",
114 wantErr: false,
115 },
116 }
117
118 for _, tt := range tests {
119 t.Run(tt.name, func(t *testing.T) {
120 if tt.setupFunc != nil {
121 tt.setupFunc()
122 }
123
124 argsCopy := make([]string, len(os.Args))
125 copy(argsCopy, os.Args)
126 defer func() { os.Args = argsCopy }()
127
128 args := []string{"--feeds-file", "/tmp/feeds.txt"}
129 if tt.prompt != "" {
130 args = append(args, "--prompt", tt.prompt)
131 }
132 os.Args = append([]string{"llm_aggregator"}, args...)
133
134 parsedArgs, err := ParseArgs()
135 if tt.wantErr {
136 if err == nil {
137 t.Errorf("Expected error for prompt %q, got nil", tt.prompt)
138 }
139 } else {
140 if err != nil {
141 t.Errorf("Unexpected error for prompt %q: %v", tt.prompt, err)
142 } else if parsedArgs.Prompt != tt.prompt {
143 t.Errorf("Prompt mismatch: want %q, got %q", tt.prompt, parsedArgs.Prompt)
144 }
145 }
146 })
147 }
148}
149
150func TestAPIKeyParsing(t *testing.T) {
151 strPtr := func(s string) *string { return &s }
152 tests := []struct {
153 name string
154 apiKey string
155 envKey string
156 wantAPIKey *string
157 wantErr bool
158 setupFunc func()
159 cleanupFunc func()
160 }{
161 {
162 name: "explicit api key",
163 apiKey: "sk-test-key-12345",
164 wantAPIKey: strPtr("sk-test-key-12345"),
165 wantErr: false,
166 },
167 {
168 name: "empty api key",
169 apiKey: "",
170 wantAPIKey: nil, // Optional field - not provided
171 wantErr: false,
172 },
173 {
174 name: "api key from environment variable - checked via config",
175 apiKey: "",
176 envKey: "sk-env-key-67890",
177 wantAPIKey: nil, // CLI parsing doesn't read env vars directly
178 wantErr: false,
179 setupFunc: func() {
180 os.Setenv("LLM_AGGREGATOR_API_KEY", "sk-env-key-67890")
181 },
182 cleanupFunc: func() {
183 os.Unsetenv("LLM_AGGREGATOR_API_KEY")
184 },
185 },
186 {
187 name: "explicit key overrides env",
188 apiKey: "sk-explicit-key",
189 envKey: "sk-env-key-should-be-ignored",
190 wantAPIKey: strPtr("sk-explicit-key"),
191 wantErr: false,
192 setupFunc: func() {
193 os.Setenv("LLM_AGGREGATOR_API_KEY", "sk-env-key-should-be-ignored")
194 },
195 cleanupFunc: func() {
196 os.Unsetenv("LLM_AGGREGATOR_API_KEY")
197 },
198 },
199 }
200
201 for _, tt := range tests {
202 t.Run(tt.name, func(t *testing.T) {
203 if tt.setupFunc != nil {
204 tt.setupFunc()
205 }
206 if tt.cleanupFunc != nil {
207 defer tt.cleanupFunc()
208 }
209
210 argsCopy := make([]string, len(os.Args))
211 copy(argsCopy, os.Args)
212 defer func() { os.Args = argsCopy }()
213
214 args := []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test prompt"}
215 if tt.apiKey != "" {
216 args = append(args, "--api-key", tt.apiKey)
217 }
218 os.Args = append([]string{"llm_aggregator"}, args...)
219
220 parsedArgs, err := ParseArgs()
221 if tt.wantErr {
222 if err == nil {
223 t.Errorf("Expected error for api-key %q, got nil", tt.apiKey)
224 }
225 } else {
226 if err != nil {
227 t.Errorf("Unexpected error for api-key: %v", err)
228 } else if (tt.wantAPIKey == nil && parsedArgs.APIKey != nil) || (tt.wantAPIKey != nil && *parsedArgs.APIKey != *tt.wantAPIKey) {
229 wantStr := "<nil>"
230 if tt.wantAPIKey != nil {
231 wantStr = *tt.wantAPIKey
232 }
233 gotStr := "<nil>"
234 if parsedArgs.APIKey != nil {
235 gotStr = *parsedArgs.APIKey
236 }
237 t.Errorf("APIKey mismatch: want %q, got %q", wantStr, gotStr)
238 }
239 }
240 })
241 }
242}
243
244func TestModelParsing(t *testing.T) {
245 strPtr := func(s string) *string { return &s }
246 tests := []struct {
247 name string
248 model string
249 wantModel *string
250 wantErr bool
251 setupFunc func()
252 cleanupFunc func()
253 }{
254 {
255 name: "default model",
256 model: "",
257 wantModel: nil, // No model provided - will default via config
258 wantErr: false,
259 },
260 {
261 name: "deepseek-chat model",
262 model: "deepseek-chat",
263 wantModel: strPtr("deepseek-chat"),
264 wantErr: false,
265 },
266 {
267 name: "deepseek-coder model",
268 model: "deepseek-coder",
269 wantModel: strPtr("deepseek-coder"),
270 wantErr: false,
271 },
272 {
273 name: "gpt-4 model",
274 model: "gpt-4",
275 wantModel: strPtr("gpt-4"),
276 wantErr: false,
277 },
278 {
279 name: "model from environment variable - checked via config",
280 model: "",
281 wantModel: nil, // CLI parsing doesn't read env vars directly
282 wantErr: false,
283 setupFunc: func() {
284 os.Setenv("LLM_AGGREGATOR_MODEL", "custom-model-from-env")
285 },
286 cleanupFunc: func() {
287 os.Unsetenv("LLM_AGGREGATOR_MODEL")
288 },
289 },
290 {
291 name: "explicit model overrides env",
292 model: "explicit-model",
293 wantModel: strPtr("explicit-model"),
294 wantErr: false,
295 setupFunc: func() {
296 os.Setenv("LLM_AGGREGATOR_MODEL", "env-model-should-be-ignored")
297 },
298 cleanupFunc: func() {
299 os.Unsetenv("LLM_AGGREGATOR_MODEL")
300 },
301 },
302 }
303
304 for _, tt := range tests {
305 t.Run(tt.name, func(t *testing.T) {
306 if tt.setupFunc != nil {
307 tt.setupFunc()
308 }
309 if tt.cleanupFunc != nil {
310 defer tt.cleanupFunc()
311 }
312
313 argsCopy := make([]string, len(os.Args))
314 copy(argsCopy, os.Args)
315 defer func() { os.Args = argsCopy }()
316
317 args := []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test prompt"}
318 if tt.model != "" {
319 args = append(args, "--model", tt.model)
320 }
321 os.Args = append([]string{"llm_aggregator"}, args...)
322
323 parsedArgs, err := ParseArgs()
324 if tt.wantErr {
325 if err == nil {
326 t.Errorf("Expected error for model %q, got nil", tt.model)
327 }
328 } else {
329 if err != nil {
330 t.Errorf("Unexpected error for model: %v", err)
331 } else if (tt.wantModel == nil && parsedArgs.Model != nil) || (tt.wantModel != nil && (parsedArgs.Model == nil || *parsedArgs.Model != *tt.wantModel)) {
332 wantStr := "<nil>"
333 if tt.wantModel != nil {
334 wantStr = *tt.wantModel
335 }
336 gotStr := "<nil>"
337 if parsedArgs.Model != nil {
338 gotStr = *parsedArgs.Model
339 }
340 t.Errorf("Model mismatch: want %q, got %q", wantStr, gotStr)
341 }
342 }
343 })
344 }
345}
346
347func TestBaseURLParsing(t *testing.T) {
348 strPtr := func(s string) *string { return &s }
349 tests := []struct {
350 name string
351 baseURL string
352 wantBaseURL *string
353 wantErr bool
354 setupFunc func()
355 cleanupFunc func()
356 }{
357 {
358 name: "default base URL",
359 baseURL: "",
360 wantBaseURL: nil, // No base URL provided
361 wantErr: false,
362 },
363 {
364 name: "explicit deepseek URL",
365 baseURL: "https://api.deepseek.com",
366 wantBaseURL: strPtr("https://api.deepseek.com"),
367 wantErr: false,
368 },
369 {
370 name: "custom OpenAI-compatible URL",
371 baseURL: "https://api.openai.com/v1",
372 wantBaseURL: strPtr("https://api.openai.com/v1"),
373 wantErr: false,
374 },
375 {
376 name: "local ollama URL",
377 baseURL: "http://localhost:11434/v1",
378 wantBaseURL: strPtr("http://localhost:11434/v1"),
379 wantErr: false,
380 },
381 {
382 name: "azure openai URL",
383 baseURL: "https://my-resource.openai.azure.com/v1",
384 wantBaseURL: strPtr("https://my-resource.openai.azure.com/v1"),
385 wantErr: false,
386 },
387 }
388
389 for _, tt := range tests {
390 t.Run(tt.name, func(t *testing.T) {
391 if tt.setupFunc != nil {
392 tt.setupFunc()
393 }
394 if tt.cleanupFunc != nil {
395 defer tt.cleanupFunc()
396 }
397
398 argsCopy := make([]string, len(os.Args))
399 copy(argsCopy, os.Args)
400 defer func() { os.Args = argsCopy }()
401
402 args := []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test prompt"}
403 if tt.baseURL != "" {
404 args = append(args, "--base-url", tt.baseURL)
405 }
406 os.Args = append([]string{"llm_aggregator"}, args...)
407
408 parsedArgs, err := ParseArgs()
409 if tt.wantErr {
410 if err == nil {
411 t.Errorf("Expected error for base-url %q, got nil", tt.baseURL)
412 }
413 } else {
414 if err != nil {
415 t.Errorf("Unexpected error for base-url: %v", err)
416 } else if (tt.wantBaseURL == nil && parsedArgs.BaseURL != nil) || (tt.wantBaseURL != nil && (parsedArgs.BaseURL == nil || *parsedArgs.BaseURL != *tt.wantBaseURL)) {
417 wantStr := "<nil>"
418 if tt.wantBaseURL != nil {
419 wantStr = *tt.wantBaseURL
420 }
421 gotStr := "<nil>"
422 if parsedArgs.BaseURL != nil {
423 gotStr = *parsedArgs.BaseURL
424 }
425 t.Errorf("BaseURL mismatch: want %q, got %q", wantStr, gotStr)
426 }
427 }
428 })
429 }
430}
431
432func TestArgsToViperMap(t *testing.T) {
433 strPtr := func(s string) *string { return &s }
434 intPtr := func(i int) *int { return &i }
435 floatPtr := func(f float64) *float64 { return &f }
436
437 args := &Args{
438 FeedsFile: "/tmp/feeds.txt",
439 Prompt: "Test prompt",
440 MaxArticlesPerFeed: intPtr(5),
441 MaxDaysOld: intPtr(14),
442 MaxTotalArticles: intPtr(50),
443 IncludeKeywords: "linux,opensource",
444 ExcludeKeywords: "advertisement",
445 APIKey: strPtr("sk-test-key"),
446 Model: strPtr("custom-model"),
447 MaxTokens: intPtr(2000),
448 Temperature: floatPtr(0.5),
449 SystemPrompt: "Custom system prompt",
450 Output: "json",
451 OutputFile: "/tmp/output.json",
452 IncludeArticles: true,
453 }
454
455 viperMap := args.ToViperMap()
456
457 tests := []struct {
458 key string
459 expected any
460 }{
461 {"max_articles_per_feed", 5},
462 {"max_days_old", 14},
463 {"max_total_articles", 50},
464 {"include_keywords", "linux,opensource"},
465 {"exclude_keywords", "advertisement"},
466 {"api_key", "sk-test-key"},
467 {"model", "custom-model"},
468 {"max_tokens", 2000},
469 {"temperature", 0.5},
470 {"system_prompt", "Custom system prompt"},
471 {"output", "json"},
472 {"output_file", "/tmp/output.json"},
473 {"include_articles", true},
474 }
475
476 for _, tt := range tests {
477 t.Run(tt.key, func(t *testing.T) {
478 if got, ok := viperMap[tt.key]; !ok {
479 t.Errorf("Key %q not found in viper map", tt.key)
480 } else if got != tt.expected {
481 t.Errorf("viperMap[%q] = %v, want %v", tt.key, got, tt.expected)
482 }
483 })
484 }
485}
486
487func TestParseKeywords(t *testing.T) {
488 tests := []struct {
489 name string
490 input string
491 expected []string
492 }{
493 {
494 name: "empty string",
495 input: "",
496 expected: nil,
497 },
498 {
499 name: "single keyword",
500 input: "linux",
501 expected: []string{"linux"},
502 },
503 {
504 name: "multiple keywords",
505 input: "linux,opensource,free-software",
506 expected: []string{"linux", "opensource", "free-software"},
507 },
508 {
509 name: "keywords with spaces",
510 input: "linux, open source, free software",
511 expected: []string{"linux", "open source", "free software"},
512 },
513 {
514 name: "keywords with extra spaces",
515 input: " linux , open source , free software ",
516 expected: []string{"linux", "open source", "free software"},
517 },
518 {
519 name: "keywords with empty items",
520 input: "linux,,opensource",
521 expected: []string{"linux", "opensource"},
522 },
523 {
524 name: "keywords with only empty items",
525 input: ",,",
526 expected: nil,
527 },
528 }
529
530 for _, tt := range tests {
531 t.Run(tt.name, func(t *testing.T) {
532 result := ParseKeywords(tt.input)
533 if len(result) != len(tt.expected) {
534 t.Errorf("ParseKeywords(%q) length = %d, want %d", tt.input, len(result), len(tt.expected))
535 return
536 }
537 for i, kw := range result {
538 if kw != tt.expected[i] {
539 t.Errorf("ParseKeywords(%q)[%d] = %q, want %q", tt.input, i, kw, tt.expected[i])
540 }
541 }
542 })
543 }
544}
545
546func TestDryRunFlag(t *testing.T) {
547 tests := []struct {
548 name string
549 args []string
550 wantDryRun bool
551 wantErr bool
552 setupFunc func()
553 cleanupFunc func()
554 }{
555 {
556 name: "dry-run flag not set",
557 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test"},
558 wantDryRun: false,
559 wantErr: false,
560 },
561 {
562 name: "dry-run flag set",
563 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run"},
564 wantDryRun: true,
565 wantErr: false,
566 },
567 {
568 name: "dry-run flag as --dry-run=true",
569 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run=true"},
570 wantDryRun: true,
571 wantErr: false,
572 },
573 {
574 name: "dry-run with TUI",
575 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run", "--tui"},
576 wantDryRun: true,
577 wantErr: false,
578 },
579 }
580
581 for _, tt := range tests {
582 t.Run(tt.name, func(t *testing.T) {
583 if tt.setupFunc != nil {
584 tt.setupFunc()
585 }
586 if tt.cleanupFunc != nil {
587 defer tt.cleanupFunc()
588 }
589
590 argsCopy := make([]string, len(os.Args))
591 copy(argsCopy, os.Args)
592 defer func() { os.Args = argsCopy }()
593
594 os.Args = append([]string{"llm_aggregator"}, tt.args...)
595
596 parsedArgs, err := ParseArgs()
597 if tt.wantErr {
598 if err == nil {
599 t.Errorf("Expected error, got nil")
600 }
601 } else {
602 if err != nil {
603 t.Errorf("Unexpected error: %v", err)
604 } else if parsedArgs.DryRun != tt.wantDryRun {
605 t.Errorf("DryRun mismatch: want %v, got %v", tt.wantDryRun, parsedArgs.DryRun)
606 }
607 }
608 })
609 }
610}