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}