all repos — llm_aggregator @ 7a6aa2c8274fa914984b3ebfbf1e5f55189451c2

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	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}