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 tests := []struct {
152 name string
153 apiKey string
154 envKey string
155 wantAPIKey string
156 wantErr bool
157 setupFunc func()
158 cleanupFunc func()
159 }{
160 {
161 name: "explicit api key",
162 apiKey: "sk-test-key-12345",
163 wantAPIKey: "sk-test-key-12345",
164 wantErr: false,
165 },
166 {
167 name: "empty api key",
168 apiKey: "",
169 wantAPIKey: "", // Optional field
170 wantErr: false,
171 },
172 {
173 name: "api key from environment variable - checked via config",
174 apiKey: "",
175 envKey: "sk-env-key-67890",
176 wantAPIKey: "", // CLI parsing doesn't read env vars directly
177 wantErr: false,
178 setupFunc: func() {
179 os.Setenv("LLM_AGGREGATOR_API_KEY", "sk-env-key-67890")
180 },
181 cleanupFunc: func() {
182 os.Unsetenv("LLM_AGGREGATOR_API_KEY")
183 },
184 },
185 {
186 name: "explicit key overrides env",
187 apiKey: "sk-explicit-key",
188 envKey: "sk-env-key-should-be-ignored",
189 wantAPIKey: "sk-explicit-key",
190 wantErr: false,
191 setupFunc: func() {
192 os.Setenv("LLM_AGGREGATOR_API_KEY", "sk-env-key-should-be-ignored")
193 },
194 cleanupFunc: func() {
195 os.Unsetenv("LLM_AGGREGATOR_API_KEY")
196 },
197 },
198 }
199
200 for _, tt := range tests {
201 t.Run(tt.name, func(t *testing.T) {
202 if tt.setupFunc != nil {
203 tt.setupFunc()
204 }
205 if tt.cleanupFunc != nil {
206 defer tt.cleanupFunc()
207 }
208
209 argsCopy := make([]string, len(os.Args))
210 copy(argsCopy, os.Args)
211 defer func() { os.Args = argsCopy }()
212
213 args := []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test prompt"}
214 if tt.apiKey != "" {
215 args = append(args, "--api-key", tt.apiKey)
216 }
217 os.Args = append([]string{"llm_aggregator"}, args...)
218
219 parsedArgs, err := ParseArgs()
220 if tt.wantErr {
221 if err == nil {
222 t.Errorf("Expected error for api-key %q, got nil", tt.apiKey)
223 }
224 } else {
225 if err != nil {
226 t.Errorf("Unexpected error for api-key: %v", err)
227 } else if parsedArgs.APIKey != tt.wantAPIKey {
228 t.Errorf("APIKey mismatch: want %q, got %q", tt.wantAPIKey, parsedArgs.APIKey)
229 }
230 }
231 })
232 }
233}
234
235func TestModelParsing(t *testing.T) {
236 tests := []struct {
237 name string
238 model string
239 wantModel string
240 wantErr bool
241 setupFunc func()
242 cleanupFunc func()
243 }{
244 {
245 name: "default model",
246 model: "",
247 wantModel: "deepseek-chat", // Default value
248 wantErr: false,
249 },
250 {
251 name: "deepseek-chat model",
252 model: "deepseek-chat",
253 wantModel: "deepseek-chat",
254 wantErr: false,
255 },
256 {
257 name: "deepseek-coder model",
258 model: "deepseek-coder",
259 wantModel: "deepseek-coder",
260 wantErr: false,
261 },
262 {
263 name: "gpt-4 model",
264 model: "gpt-4",
265 wantModel: "gpt-4",
266 wantErr: false,
267 },
268 {
269 name: "model from environment variable - checked via config",
270 model: "",
271 wantModel: "deepseek-chat", // CLI parsing doesn't read env vars directly
272 wantErr: false,
273 setupFunc: func() {
274 os.Setenv("LLM_AGGREGATOR_MODEL", "custom-model-from-env")
275 },
276 cleanupFunc: func() {
277 os.Unsetenv("LLM_AGGREGATOR_MODEL")
278 },
279 },
280 {
281 name: "explicit model overrides env",
282 model: "explicit-model",
283 wantModel: "explicit-model",
284 wantErr: false,
285 setupFunc: func() {
286 os.Setenv("LLM_AGGREGATOR_MODEL", "env-model-should-be-ignored")
287 },
288 cleanupFunc: func() {
289 os.Unsetenv("LLM_AGGREGATOR_MODEL")
290 },
291 },
292 }
293
294 for _, tt := range tests {
295 t.Run(tt.name, func(t *testing.T) {
296 if tt.setupFunc != nil {
297 tt.setupFunc()
298 }
299 if tt.cleanupFunc != nil {
300 defer tt.cleanupFunc()
301 }
302
303 argsCopy := make([]string, len(os.Args))
304 copy(argsCopy, os.Args)
305 defer func() { os.Args = argsCopy }()
306
307 args := []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test prompt"}
308 if tt.model != "" {
309 args = append(args, "--model", tt.model)
310 }
311 os.Args = append([]string{"llm_aggregator"}, args...)
312
313 parsedArgs, err := ParseArgs()
314 if tt.wantErr {
315 if err == nil {
316 t.Errorf("Expected error for model %q, got nil", tt.model)
317 }
318 } else {
319 if err != nil {
320 t.Errorf("Unexpected error for model: %v", err)
321 } else if parsedArgs.Model != tt.wantModel {
322 t.Errorf("Model mismatch: want %q, got %q", tt.wantModel, parsedArgs.Model)
323 }
324 }
325 })
326 }
327}
328
329func TestBaseURLParsing(t *testing.T) {
330 tests := []struct {
331 name string
332 baseURL string
333 wantBaseURL string
334 wantErr bool
335 setupFunc func()
336 cleanupFunc func()
337 }{
338 {
339 name: "default base URL",
340 baseURL: "",
341 wantBaseURL: "https://api.deepseek.com",
342 wantErr: false,
343 },
344 {
345 name: "explicit deepseek URL",
346 baseURL: "https://api.deepseek.com",
347 wantBaseURL: "https://api.deepseek.com",
348 wantErr: false,
349 },
350 {
351 name: "custom OpenAI-compatible URL",
352 baseURL: "https://api.openai.com/v1",
353 wantBaseURL: "https://api.openai.com/v1",
354 wantErr: false,
355 },
356 {
357 name: "local ollama URL",
358 baseURL: "http://localhost:11434/v1",
359 wantBaseURL: "http://localhost:11434/v1",
360 wantErr: false,
361 },
362 {
363 name: "azure openai URL",
364 baseURL: "https://my-resource.openai.azure.com/v1",
365 wantBaseURL: "https://my-resource.openai.azure.com/v1",
366 wantErr: false,
367 },
368 }
369
370 for _, tt := range tests {
371 t.Run(tt.name, func(t *testing.T) {
372 if tt.setupFunc != nil {
373 tt.setupFunc()
374 }
375 if tt.cleanupFunc != nil {
376 defer tt.cleanupFunc()
377 }
378
379 argsCopy := make([]string, len(os.Args))
380 copy(argsCopy, os.Args)
381 defer func() { os.Args = argsCopy }()
382
383 args := []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test prompt"}
384 if tt.baseURL != "" {
385 args = append(args, "--base-url", tt.baseURL)
386 }
387 os.Args = append([]string{"llm_aggregator"}, args...)
388
389 parsedArgs, err := ParseArgs()
390 if tt.wantErr {
391 if err == nil {
392 t.Errorf("Expected error for base-url %q, got nil", tt.baseURL)
393 }
394 } else {
395 if err != nil {
396 t.Errorf("Unexpected error for base-url: %v", err)
397 } else if parsedArgs.BaseURL != tt.wantBaseURL {
398 t.Errorf("BaseURL mismatch: want %q, got %q", tt.wantBaseURL, parsedArgs.BaseURL)
399 }
400 }
401 })
402 }
403}
404
405func TestArgsToViperMap(t *testing.T) {
406 args := &Args{
407 FeedsFile: "/tmp/feeds.txt",
408 Prompt: "Test prompt",
409 MaxArticlesPerFeed: 5,
410 MaxDaysOld: 14,
411 MaxTotalArticles: 50,
412 IncludeKeywords: "linux,opensource",
413 ExcludeKeywords: "advertisement",
414 APIKey: "sk-test-key",
415 Model: "custom-model",
416 MaxTokens: 2000,
417 Temperature: 0.5,
418 SystemPrompt: "Custom system prompt",
419 Output: "json",
420 OutputFile: "/tmp/output.json",
421 IncludeArticles: true,
422 }
423
424 viperMap := args.ToViperMap()
425
426 tests := []struct {
427 key string
428 expected any
429 }{
430 {"max_articles_per_feed", 5},
431 {"max_days_old", 14},
432 {"max_total_articles", 50},
433 {"include_keywords", "linux,opensource"},
434 {"exclude_keywords", "advertisement"},
435 {"api_key", "sk-test-key"},
436 {"model", "custom-model"},
437 {"max_tokens", 2000},
438 {"temperature", 0.5},
439 {"system_prompt", "Custom system prompt"},
440 {"output", "json"},
441 {"output_file", "/tmp/output.json"},
442 {"include_articles", true},
443 }
444
445 for _, tt := range tests {
446 t.Run(tt.key, func(t *testing.T) {
447 if got, ok := viperMap[tt.key]; !ok {
448 t.Errorf("Key %q not found in viper map", tt.key)
449 } else if got != tt.expected {
450 t.Errorf("viperMap[%q] = %v, want %v", tt.key, got, tt.expected)
451 }
452 })
453 }
454}
455
456func TestParseKeywords(t *testing.T) {
457 tests := []struct {
458 name string
459 input string
460 expected []string
461 }{
462 {
463 name: "empty string",
464 input: "",
465 expected: nil,
466 },
467 {
468 name: "single keyword",
469 input: "linux",
470 expected: []string{"linux"},
471 },
472 {
473 name: "multiple keywords",
474 input: "linux,opensource,free-software",
475 expected: []string{"linux", "opensource", "free-software"},
476 },
477 {
478 name: "keywords with spaces",
479 input: "linux, open source, free software",
480 expected: []string{"linux", "open source", "free software"},
481 },
482 {
483 name: "keywords with extra spaces",
484 input: " linux , open source , free software ",
485 expected: []string{"linux", "open source", "free software"},
486 },
487 {
488 name: "keywords with empty items",
489 input: "linux,,opensource",
490 expected: []string{"linux", "opensource"},
491 },
492 {
493 name: "keywords with only empty items",
494 input: ",,",
495 expected: nil,
496 },
497 }
498
499 for _, tt := range tests {
500 t.Run(tt.name, func(t *testing.T) {
501 result := ParseKeywords(tt.input)
502 if len(result) != len(tt.expected) {
503 t.Errorf("ParseKeywords(%q) length = %d, want %d", tt.input, len(result), len(tt.expected))
504 return
505 }
506 for i, kw := range result {
507 if kw != tt.expected[i] {
508 t.Errorf("ParseKeywords(%q)[%d] = %q, want %q", tt.input, i, kw, tt.expected[i])
509 }
510 }
511 })
512 }
513}
514
515func TestDryRunFlag(t *testing.T) {
516 tests := []struct {
517 name string
518 args []string
519 wantDryRun bool
520 wantErr bool
521 setupFunc func()
522 cleanupFunc func()
523 }{
524 {
525 name: "dry-run flag not set",
526 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test"},
527 wantDryRun: false,
528 wantErr: false,
529 },
530 {
531 name: "dry-run flag set",
532 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run"},
533 wantDryRun: true,
534 wantErr: false,
535 },
536 {
537 name: "dry-run flag as --dry-run=true",
538 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run=true"},
539 wantDryRun: true,
540 wantErr: false,
541 },
542 {
543 name: "dry-run with TUI",
544 args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run", "--tui"},
545 wantDryRun: true,
546 wantErr: false,
547 },
548 }
549
550 for _, tt := range tests {
551 t.Run(tt.name, func(t *testing.T) {
552 if tt.setupFunc != nil {
553 tt.setupFunc()
554 }
555 if tt.cleanupFunc != nil {
556 defer tt.cleanupFunc()
557 }
558
559 argsCopy := make([]string, len(os.Args))
560 copy(argsCopy, os.Args)
561 defer func() { os.Args = argsCopy }()
562
563 os.Args = append([]string{"llm_aggregator"}, tt.args...)
564
565 parsedArgs, err := ParseArgs()
566 if tt.wantErr {
567 if err == nil {
568 t.Errorf("Expected error, got nil")
569 }
570 } else {
571 if err != nil {
572 t.Errorf("Unexpected error: %v", err)
573 } else if parsedArgs.DryRun != tt.wantDryRun {
574 t.Errorf("DryRun mismatch: want %v, got %v", tt.wantDryRun, parsedArgs.DryRun)
575 }
576 }
577 })
578 }
579}