all repos — llm_aggregator @ e6f508220e5dbe8a9333b4ad603b5add4b13af7b

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

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}