all repos — llm_aggregator @ 2d9899ec7612acc35f5ae4e343d6364a9f2dc8b1

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

internal/aggregator/aggregator_test.go (view raw)

  1package aggregator
  2
  3import (
  4	"os"
  5	"strings"
  6	"testing"
  7	"time"
  8
  9	"llm_aggregator/internal/progress"
 10)
 11
 12// Mock feed data for testing
 13const validRSSFeed = `<?xml version="1.0" encoding="UTF-8"?>
 14<rss version="2.0">
 15  <channel>
 16    <title>Test Feed</title>
 17    <link>https://example.com/feed</link>
 18    <description>A test RSS feed</description>
 19    <item>
 20      <title>Article One</title>
 21      <link>https://example.com/article1</link>
 22      <description>This is the content of article one.</description>
 23      <pubDate>Wed, 15 Jan 2024 10:00:00 GMT</pubDate>
 24      <author>john@example.com (John Doe)</author>
 25    </item>
 26    <item>
 27      <title>Article Two</title>
 28      <link>https://example.com/article2</link>
 29      <description>This is the content of article two.</description>
 30      <pubDate>Tue, 14 Jan 2024 09:00:00 GMT</pubDate>
 31      <author>jane@example.com</author>
 32    </item>
 33  </channel>
 34</rss>`
 35
 36const atomFeed = `<?xml version="1.0" encoding="UTF-8"?>
 37<feed xmlns="http://www.w3.org/2005/Atom">
 38  <title>Atom Test Feed</title>
 39  <link href="https://example.com/atom"/>
 40  <entry>
 41    <title>Atom Article One</title>
 42    <link href="https://example.com/atom1"/>
 43    <content>This is atom article content.</content>
 44    <updated>2024-01-15T10:00:00Z</updated>
 45    <author><name>Atom Author</name></author>
 46  </entry>
 47</feed>`
 48
 49const malformedRSSFeed = `<?xml version="1.0" encoding="UTF-8"?>
 50<rss version="2.0">
 51  <channel>
 52    <title>Malformed Feed</title>
 53    <item>
 54      <title>Incomplete Article</title>
 55      <!-- missing link -->
 56    </item>
 57  </channel>
 58</rss>`
 59
 60func TestNewFeedAggregatorWithProgress(t *testing.T) {
 61	t.Run("with nil progress context", func(t *testing.T) {
 62		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
 63		if fa == nil {
 64			t.Error("Expected non-nil FeedAggregator")
 65		}
 66		if fa.progressCtx != nil {
 67			t.Error("Expected nil progress context")
 68		}
 69	})
 70}
 71
 72func TestParseFeedsFromFileNotFound(t *testing.T) {
 73	fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
 74
 75	_, err := fa.ParseFeedsFromFile("/nonexistent/path/to/feeds.txt")
 76	if err == nil {
 77		t.Error("Expected error for non-existent file")
 78	}
 79	if !strings.Contains(err.Error(), "failed to open feeds file") {
 80		t.Errorf("Expected 'failed to open feeds file' error, got: %v", err)
 81	}
 82}
 83
 84func TestParseFeedsFromFileEmpty(t *testing.T) {
 85	tmpDir := t.TempDir()
 86	tmpFile := tmpDir + "/test_feeds.txt"
 87
 88	t.Run("empty file", func(t *testing.T) {
 89		if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil {
 90			t.Fatalf("Failed to create test file: %v", err)
 91		}
 92
 93		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
 94		articles, err := fa.ParseFeedsFromFile(tmpFile)
 95
 96		// Empty file should not cause an error
 97		if err != nil {
 98			t.Errorf("Unexpected error: %v", err)
 99		}
100		if len(articles) != 0 {
101			t.Errorf("Expected 0 articles, got %d", len(articles))
102		}
103	})
104
105	t.Run("file with only comments", func(t *testing.T) {
106		if err := os.WriteFile(tmpFile, []byte("# Comment line\n# Another comment\n"), 0644); err != nil {
107			t.Fatalf("Failed to create test file: %v", err)
108		}
109
110		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
111		articles, err := fa.ParseFeedsFromFile(tmpFile)
112
113		if err != nil {
114			t.Errorf("Unexpected error: %v", err)
115		}
116		if len(articles) != 0 {
117			t.Errorf("Expected 0 articles, got %d", len(articles))
118		}
119	})
120
121	t.Run("file with comments and URLs", func(t *testing.T) {
122		content := "# Header comment\nhttps://example.com/feed1\n# Inline comment\nhttps://example.com/feed2\n"
123		if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
124			t.Fatalf("Failed to create test file: %v", err)
125		}
126
127		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
128		// This will fail to fetch but shouldn't crash
129		_, err := fa.ParseFeedsFromFile(tmpFile)
130
131		// We expect network errors but not parsing errors
132		// (If feeds are unreachable, that's okay for this test)
133		if err != nil && strings.Contains(err.Error(), "failed to open") {
134			t.Errorf("File parsing error: %v", err)
135		}
136	})
137}
138
139func writeTestFile(path, content string) error {
140	return writeFile(path, []byte(content))
141}
142
143func writeFile(path string, data []byte) error {
144	f, err := createFile(path)
145	if err != nil {
146		return err
147	}
148	defer f.Close()
149	_, err = f.Write(data)
150	return err
151}
152
153func createFile(path string) (*testFile, error) {
154	return &testFile{path: path}, nil
155}
156
157type testFile struct {
158	path string
159}
160
161func (tf *testFile) Write(data []byte) (int, error) {
162	// Using os.Write would be cleaner but let's use os package directly
163	return len(data), nil
164}
165
166func (tf *testFile) Close() error {
167	return nil
168}
169
170// Helper to actually write to temp file
171func writeTempFile(t *testing.T, content string) string {
172	t.Helper()
173	tmpFile := "/tmp/llm_aggregator_test_feeds.txt"
174	err := os.WriteFile(tmpFile, []byte(content), 0644)
175	if err != nil {
176		t.Fatalf("Failed to write temp file: %v", err)
177	}
178	return tmpFile
179}
180
181func TestArticleAgeFiltering(t *testing.T) {
182	// FeedAggregator configured for 7-day max article age
183	now := time.Now()
184	recentDate := now.Add(-3 * 24 * time.Hour)  // 3 days old
185	oldDate := now.Add(-10 * 24 * time.Hour)    // 10 days old
186
187	t.Run("article within age limit", func(t *testing.T) {
188		article := &Article{
189			Title:     "Recent Article",
190			Link:      "https://example.com/recent",
191			Content:   "Content",
192			Published: recentDate,
193		}
194
195		cutoffTime := now.Add(-7 * 24 * time.Hour)
196		isOld := article.Published.Before(cutoffTime)
197
198		if isOld {
199			t.Error("Recent article should not be considered old")
200		}
201	})
202
203	t.Run("article outside age limit", func(t *testing.T) {
204		article := &Article{
205			Title:     "Old Article",
206			Link:      "https://example.com/old",
207			Content:   "Content",
208			Published: oldDate,
209		}
210
211		cutoffTime := now.Add(-7 * 24 * time.Hour)
212		isOld := article.Published.Before(cutoffTime)
213
214		if !isOld {
215			t.Error("Old article should be considered old")
216		}
217	})
218}
219
220func TestArticleSorting(t *testing.T) {
221	now := time.Now()
222	articles := []*Article{
223		{
224			Title:     "Third Article",
225			Link:      "https://example.com/third",
226			Published: now.Add(-3 * 24 * time.Hour),
227		},
228		{
229			Title:     "First Article",
230			Link:      "https://example.com/first",
231			Published: now.Add(-1 * 24 * time.Hour),
232		},
233		{
234			Title:     "Second Article",
235			Link:      "https://example.com/second",
236			Published: now.Add(-2 * 24 * time.Hour),
237		},
238	}
239
240	t.Run("sort by date ascending", func(t *testing.T) {
241		sorted := sortByDate(articles, false)
242
243		// Ascending: oldest first (Third, Second, First)
244		if sorted[0].Title != "Third Article" {
245			t.Errorf("Expected Third Article first, got %s", sorted[0].Title)
246		}
247		if sorted[1].Title != "Second Article" {
248			t.Errorf("Expected Second Article second, got %s", sorted[1].Title)
249		}
250		if sorted[2].Title != "First Article" {
251			t.Errorf("Expected First Article third, got %s", sorted[2].Title)
252		}
253	})
254
255	t.Run("sort by date descending", func(t *testing.T) {
256		sorted := sortByDate(articles, true)
257
258		// Descending: newest first (First, Second, Third)
259		if sorted[0].Title != "First Article" {
260			t.Errorf("Expected First Article first, got %s", sorted[0].Title)
261		}
262		if sorted[1].Title != "Second Article" {
263			t.Errorf("Expected Second Article second, got %s", sorted[1].Title)
264		}
265		if sorted[2].Title != "Third Article" {
266			t.Errorf("Expected Third Article third, got %s", sorted[2].Title)
267		}
268	})
269}
270
271// sortByDate is a standalone test helper that sorts by date
272func sortByDate(articles []*Article, reverse bool) []*Article {
273	result := make([]*Article, len(articles))
274	copy(result, articles)
275
276	// Simple bubble sort for testing
277	for i := 0; i < len(result)-1; i++ {
278		for j := 0; j < len(result)-i-1; j++ {
279			iTime := result[j].Published
280			jTime := result[j+1].Published
281			if iTime.IsZero() {
282				iTime = time.Time{}
283			}
284			if jTime.IsZero() {
285				jTime = time.Time{}
286			}
287
288			var shouldSwap bool
289			if reverse {
290				shouldSwap = iTime.Before(jTime)
291			} else {
292				shouldSwap = iTime.After(jTime)
293			}
294
295			if shouldSwap {
296				result[j], result[j+1] = result[j+1], result[j]
297			}
298		}
299	}
300
301	return result
302}
303
304func TestArticleLinkExtraction(t *testing.T) {
305	tests := []struct {
306		name     string
307		item     *Article
308		wantLink string
309	}{
310		{
311			name:     "valid link",
312			item:     &Article{Link: "https://example.com/valid"},
313			wantLink: "https://example.com/valid",
314		},
315		{
316			name:     "empty link",
317			item:     &Article{Link: ""},
318			wantLink: "",
319		},
320		{
321			name:     "relative link",
322			item:     &Article{Link: "/article/path"},
323			wantLink: "/article/path",
324		},
325	}
326
327	for _, tt := range tests {
328		t.Run(tt.name, func(t *testing.T) {
329			if tt.item.Link != tt.wantLink {
330				t.Errorf("Link = %q, want %q", tt.item.Link, tt.wantLink)
331			}
332		})
333	}
334}
335
336func TestArticleContentExtraction(t *testing.T) {
337	t.Run("long content is truncated", func(t *testing.T) {
338		longContent := strings.Repeat("x", 600)
339		article := &Article{
340			Title:   "Long Content Article",
341			Link:    "https://example.com/long",
342			Content: longContent,
343		}
344
345		// ToMap truncates content over 500 chars
346		m := article.ToMap()
347		content := m["content"].(string)
348
349		// 500 chars + "... [truncated]" (11 chars) = 511 chars
350		expectedLen := 500 + len("... [truncated]")
351		if len(content) != expectedLen {
352			t.Errorf("Expected %d chars (500 + truncation), got %d", expectedLen, len(content))
353		}
354		if !strings.HasSuffix(content, "... [truncated]") {
355			t.Errorf("Expected content to end with truncation marker")
356		}
357	})
358
359	t.Run("short content is not truncated", func(t *testing.T) {
360		shortContent := "Short content"
361		article := &Article{
362			Title:   "Short Content Article",
363			Link:    "https://example.com/short",
364			Content: shortContent,
365		}
366
367		m := article.ToMap()
368		if m["content"] != shortContent {
369			t.Errorf("Content should not be truncated: %s", m["content"])
370		}
371	})
372}
373
374func TestEmptyFeedsFile(t *testing.T) {
375	tmpDir := t.TempDir()
376	tmpFile := tmpDir + "/empty_feeds.txt"
377
378	// Create empty file
379	if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil {
380		t.Fatalf("Failed to write temp file: %v", err)
381	}
382
383	// Use WithProgress to avoid nil pointer dereference
384	fa := NewFeedAggregatorWithProgress(10, 7, 5000, progress.NewContext(&progress.NoopLogger{}))
385	articles, err := fa.ParseFeedsFromFile(tmpFile)
386
387	if err != nil {
388		t.Errorf("Expected no error for empty file, got: %v", err)
389	}
390	if len(articles) != 0 {
391		t.Errorf("Expected 0 articles from empty file, got %d", len(articles))
392	}
393}
394
395func TestParseFeedFromReader(t *testing.T) {
396	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
397
398	articles, err := fa.ParseFeedFromReader(strings.NewReader(validRSSFeed), "test-reader")
399	if err != nil {
400		t.Fatalf("Unexpected error parsing RSS feed: %v", err)
401	}
402	if len(articles) != 2 {
403		t.Errorf("Expected 2 articles, got %d", len(articles))
404	}
405
406	// Check first article
407	if articles[0].Title != "Article One" {
408		t.Errorf("Expected title 'Article One', got %q", articles[0].Title)
409	}
410	if articles[0].SourceFeed != "Test Feed" {
411		t.Errorf("Expected source feed 'Test Feed', got %q", articles[0].SourceFeed)
412	}
413
414	// Check second article
415	if articles[1].Title != "Article Two" {
416		t.Errorf("Expected title 'Article Two', got %q", articles[1].Title)
417	}
418}
419
420func TestParseFeedFromReaderAtom(t *testing.T) {
421	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
422
423	articles, err := fa.ParseFeedFromReader(strings.NewReader(atomFeed), "atom-reader")
424	if err != nil {
425		t.Fatalf("Unexpected error parsing Atom feed: %v", err)
426	}
427	if len(articles) != 1 {
428		t.Errorf("Expected 1 article, got %d", len(articles))
429	}
430
431	if articles[0].Title != "Atom Article One" {
432		t.Errorf("Expected title 'Atom Article One', got %q", articles[0].Title)
433	}
434	if articles[0].Author != "Atom Author" {
435		t.Errorf("Expected author 'Atom Author', got %q", articles[0].Author)
436	}
437}
438
439func TestParseFeedFromReaderMalformed(t *testing.T) {
440	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
441
442	// Truly malformed XML should return an error
443	_, err := fa.ParseFeedFromReader(strings.NewReader("not valid xml at all"), "malformed-reader")
444	if err == nil {
445		t.Error("Expected error for malformed content, got nil")
446	}
447}
448
449func TestParseFeedFromStdin(t *testing.T) {
450	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
451
452	// Read from a pipes reader to avoid blocking on real stdin
453	r, w, _ := os.Pipe()
454	go func() {
455		w.WriteString(validRSSFeed)
456		w.Close()
457	}()
458
459	articles, err := fa.ParseFeedFromReader(r, "stdin")
460	if err != nil {
461		t.Fatalf("Unexpected error: %v", err)
462	}
463	if len(articles) != 2 {
464		t.Errorf("Expected 2 articles, got %d", len(articles))
465	}
466}