all repos — llm_aggregator @ 97ccb8da8015d57585d37a5d27e94ada7f766922

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