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}