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}