all repos — llm_aggregator @ e3e7be8461bb50a7fe674b4055286d34dddb0c5c

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
 49func TestNewFeedAggregatorWithProgress(t *testing.T) {
 50	t.Run("with nil progress context", func(t *testing.T) {
 51		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
 52		if fa == nil {
 53			t.Error("Expected non-nil FeedAggregator")
 54			return
 55		}
 56		if fa.progressCtx != nil {
 57			t.Error("Expected nil progress context")
 58		}
 59	})
 60}
 61
 62func TestParseFeedsFromFileNotFound(t *testing.T) {
 63	fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
 64
 65	_, err := fa.ParseFeedsFromFile("/nonexistent/path/to/feeds.txt")
 66	if err == nil {
 67		t.Error("Expected error for non-existent file")
 68	}
 69	if !strings.Contains(err.Error(), "failed to open feeds file") {
 70		t.Errorf("Expected 'failed to open feeds file' error, got: %v", err)
 71	}
 72}
 73
 74func TestParseFeedsFromFileEmpty(t *testing.T) {
 75	tmpDir := t.TempDir()
 76	tmpFile := tmpDir + "/test_feeds.txt"
 77
 78	t.Run("empty file", func(t *testing.T) {
 79		if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil {
 80			t.Fatalf("Failed to create test file: %v", err)
 81		}
 82
 83		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
 84		articles, err := fa.ParseFeedsFromFile(tmpFile)
 85
 86		// Empty file should not cause an error
 87		if err != nil {
 88			t.Errorf("Unexpected error: %v", err)
 89		}
 90		if len(articles) != 0 {
 91			t.Errorf("Expected 0 articles, got %d", len(articles))
 92		}
 93	})
 94
 95	t.Run("file with only comments", func(t *testing.T) {
 96		if err := os.WriteFile(tmpFile, []byte("# Comment line\n# Another comment\n"), 0644); err != nil {
 97			t.Fatalf("Failed to create test file: %v", err)
 98		}
 99
100		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
101		articles, err := fa.ParseFeedsFromFile(tmpFile)
102
103		if err != nil {
104			t.Errorf("Unexpected error: %v", err)
105		}
106		if len(articles) != 0 {
107			t.Errorf("Expected 0 articles, got %d", len(articles))
108		}
109	})
110
111	t.Run("file with comments and URLs", func(t *testing.T) {
112		content := "# Header comment\nhttps://example.com/feed1\n# Inline comment\nhttps://example.com/feed2\n"
113		if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
114			t.Fatalf("Failed to create test file: %v", err)
115		}
116
117		fa := NewFeedAggregatorWithProgress(10, 7, 5000, nil)
118		// This will fail to fetch but shouldn't crash
119		_, err := fa.ParseFeedsFromFile(tmpFile)
120
121		// We expect network errors but not parsing errors
122		// (If feeds are unreachable, that's okay for this test)
123		if err != nil && strings.Contains(err.Error(), "failed to open") {
124			t.Errorf("File parsing error: %v", err)
125		}
126	})
127}
128
129func TestArticleAgeFiltering(t *testing.T) {
130	// FeedAggregator configured for 7-day max article age
131	now := time.Now()
132	recentDate := now.Add(-3 * 24 * time.Hour)  // 3 days old
133	oldDate := now.Add(-10 * 24 * time.Hour)    // 10 days old
134
135	t.Run("article within age limit", func(t *testing.T) {
136		article := &Article{
137			Title:     "Recent Article",
138			Link:      "https://example.com/recent",
139			Content:   "Content",
140			Published: recentDate,
141		}
142
143		cutoffTime := now.Add(-7 * 24 * time.Hour)
144		isOld := article.Published.Before(cutoffTime)
145
146		if isOld {
147			t.Error("Recent article should not be considered old")
148		}
149	})
150
151	t.Run("article outside age limit", func(t *testing.T) {
152		article := &Article{
153			Title:     "Old Article",
154			Link:      "https://example.com/old",
155			Content:   "Content",
156			Published: oldDate,
157		}
158
159		cutoffTime := now.Add(-7 * 24 * time.Hour)
160		isOld := article.Published.Before(cutoffTime)
161
162		if !isOld {
163			t.Error("Old article should be considered old")
164		}
165	})
166}
167
168func TestArticleSorting(t *testing.T) {
169	now := time.Now()
170	articles := []*Article{
171		{
172			Title:     "Third Article",
173			Link:      "https://example.com/third",
174			Published: now.Add(-3 * 24 * time.Hour),
175		},
176		{
177			Title:     "First Article",
178			Link:      "https://example.com/first",
179			Published: now.Add(-1 * 24 * time.Hour),
180		},
181		{
182			Title:     "Second Article",
183			Link:      "https://example.com/second",
184			Published: now.Add(-2 * 24 * time.Hour),
185		},
186	}
187
188	t.Run("sort by date ascending", func(t *testing.T) {
189		sorted := sortByDate(articles, false)
190
191		// Ascending: oldest first (Third, Second, First)
192		if sorted[0].Title != "Third Article" {
193			t.Errorf("Expected Third Article first, got %s", sorted[0].Title)
194		}
195		if sorted[1].Title != "Second Article" {
196			t.Errorf("Expected Second Article second, got %s", sorted[1].Title)
197		}
198		if sorted[2].Title != "First Article" {
199			t.Errorf("Expected First Article third, got %s", sorted[2].Title)
200		}
201	})
202
203	t.Run("sort by date descending", func(t *testing.T) {
204		sorted := sortByDate(articles, true)
205
206		// Descending: newest first (First, Second, Third)
207		if sorted[0].Title != "First Article" {
208			t.Errorf("Expected First Article first, got %s", sorted[0].Title)
209		}
210		if sorted[1].Title != "Second Article" {
211			t.Errorf("Expected Second Article second, got %s", sorted[1].Title)
212		}
213		if sorted[2].Title != "Third Article" {
214			t.Errorf("Expected Third Article third, got %s", sorted[2].Title)
215		}
216	})
217}
218
219// sortByDate is a standalone test helper that sorts by date
220func sortByDate(articles []*Article, reverse bool) []*Article {
221	result := make([]*Article, len(articles))
222	copy(result, articles)
223
224	// Simple bubble sort for testing
225	for i := 0; i < len(result)-1; i++ {
226		for j := 0; j < len(result)-i-1; j++ {
227			iTime := result[j].Published
228			jTime := result[j+1].Published
229			if iTime.IsZero() {
230				iTime = time.Time{}
231			}
232			if jTime.IsZero() {
233				jTime = time.Time{}
234			}
235
236			var shouldSwap bool
237			if reverse {
238				shouldSwap = iTime.Before(jTime)
239			} else {
240				shouldSwap = iTime.After(jTime)
241			}
242
243			if shouldSwap {
244				result[j], result[j+1] = result[j+1], result[j]
245			}
246		}
247	}
248
249	return result
250}
251
252func TestArticleLinkExtraction(t *testing.T) {
253	tests := []struct {
254		name     string
255		item     *Article
256		wantLink string
257	}{
258		{
259			name:     "valid link",
260			item:     &Article{Link: "https://example.com/valid"},
261			wantLink: "https://example.com/valid",
262		},
263		{
264			name:     "empty link",
265			item:     &Article{Link: ""},
266			wantLink: "",
267		},
268		{
269			name:     "relative link",
270			item:     &Article{Link: "/article/path"},
271			wantLink: "/article/path",
272		},
273	}
274
275	for _, tt := range tests {
276		t.Run(tt.name, func(t *testing.T) {
277			if tt.item.Link != tt.wantLink {
278				t.Errorf("Link = %q, want %q", tt.item.Link, tt.wantLink)
279			}
280		})
281	}
282}
283
284func TestArticleContentExtraction(t *testing.T) {
285	t.Run("long content is truncated", func(t *testing.T) {
286		longContent := strings.Repeat("x", 600)
287		article := &Article{
288			Title:   "Long Content Article",
289			Link:    "https://example.com/long",
290			Content: longContent,
291		}
292
293		// ToMap truncates content over 500 chars
294		m := article.ToMap()
295		content := m["content"].(string)
296
297		// 500 chars + "... [truncated]" (11 chars) = 511 chars
298		expectedLen := 500 + len("... [truncated]")
299		if len(content) != expectedLen {
300			t.Errorf("Expected %d chars (500 + truncation), got %d", expectedLen, len(content))
301		}
302		if !strings.HasSuffix(content, "... [truncated]") {
303			t.Errorf("Expected content to end with truncation marker")
304		}
305	})
306
307	t.Run("short content is not truncated", func(t *testing.T) {
308		shortContent := "Short content"
309		article := &Article{
310			Title:   "Short Content Article",
311			Link:    "https://example.com/short",
312			Content: shortContent,
313		}
314
315		m := article.ToMap()
316		if m["content"] != shortContent {
317			t.Errorf("Content should not be truncated: %s", m["content"])
318		}
319	})
320}
321
322func TestEmptyFeedsFile(t *testing.T) {
323	tmpDir := t.TempDir()
324	tmpFile := tmpDir + "/empty_feeds.txt"
325
326	// Create empty file
327	if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil {
328		t.Fatalf("Failed to write temp file: %v", err)
329	}
330
331	// Use WithProgress to avoid nil pointer dereference
332	fa := NewFeedAggregatorWithProgress(10, 7, 5000, progress.NewContext(&progress.NoopLogger{}))
333	articles, err := fa.ParseFeedsFromFile(tmpFile)
334
335	if err != nil {
336		t.Errorf("Expected no error for empty file, got: %v", err)
337	}
338	if len(articles) != 0 {
339		t.Errorf("Expected 0 articles from empty file, got %d", len(articles))
340	}
341}
342
343func TestParseFeedFromReader(t *testing.T) {
344	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
345
346	articles, err := fa.ParseFeedFromReader(strings.NewReader(validRSSFeed), "test-reader")
347	if err != nil {
348		t.Fatalf("Unexpected error parsing RSS feed: %v", err)
349	}
350	if len(articles) != 2 {
351		t.Errorf("Expected 2 articles, got %d", len(articles))
352	}
353
354	// Check first article
355	if articles[0].Title != "Article One" {
356		t.Errorf("Expected title 'Article One', got %q", articles[0].Title)
357	}
358	if articles[0].SourceFeed != "Test Feed" {
359		t.Errorf("Expected source feed 'Test Feed', got %q", articles[0].SourceFeed)
360	}
361
362	// Check second article
363	if articles[1].Title != "Article Two" {
364		t.Errorf("Expected title 'Article Two', got %q", articles[1].Title)
365	}
366}
367
368func TestParseFeedFromReaderAtom(t *testing.T) {
369	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
370
371	articles, err := fa.ParseFeedFromReader(strings.NewReader(atomFeed), "atom-reader")
372	if err != nil {
373		t.Fatalf("Unexpected error parsing Atom feed: %v", err)
374	}
375	if len(articles) != 1 {
376		t.Errorf("Expected 1 article, got %d", len(articles))
377	}
378
379	if articles[0].Title != "Atom Article One" {
380		t.Errorf("Expected title 'Atom Article One', got %q", articles[0].Title)
381	}
382	if articles[0].Author != "Atom Author" {
383		t.Errorf("Expected author 'Atom Author', got %q", articles[0].Author)
384	}
385}
386
387func TestParseFeedFromReaderMalformed(t *testing.T) {
388	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
389
390	// Truly malformed XML should return an error
391	_, err := fa.ParseFeedFromReader(strings.NewReader("not valid xml at all"), "malformed-reader")
392	if err == nil {
393		t.Error("Expected error for malformed content, got nil")
394	}
395}
396
397func TestParseFeedFromStdin(t *testing.T) {
398	fa := NewFeedAggregatorWithProgress(10, 0, 5000, nil)
399
400	// Read from a pipes reader to avoid blocking on real stdin
401	r, w, _ := os.Pipe()
402	go func() {
403		_, _ = w.WriteString(validRSSFeed) //nolint:errcheck
404		w.Close()                           //nolint:errcheck
405	}()
406
407	articles, err := fa.ParseFeedFromReader(r, "stdin")
408	if err != nil {
409		t.Fatalf("Unexpected error: %v", err)
410	}
411	if len(articles) != 2 {
412		t.Errorf("Expected 2 articles, got %d", len(articles))
413	}
414}