Skip to main content

max / balanced_breakfast

40.6 KB · 1053 lines History Blame Raw
1 //! Query and pagination methods for the feed generator.
2
3 use bb_interface::FeedItem;
4 use tracing::debug;
5
6 use super::{FeedError, FeedGenerator, PaginatedItems, SourceInfo};
7
8 impl FeedGenerator {
9 /// Get a page of feed items with pagination metadata.
10 ///
11 /// When a search query is present, the search predicate is pushed into SQL
12 /// so that LIMIT/OFFSET pagination works correctly with filtered results.
13 /// Other filters (source, unread, starred) are combined in the same query.
14 ///
15 /// Fetches `page_size + 1` items to detect whether more pages exist,
16 /// then applies in-memory ordering before truncating to the exact page size.
17 #[tracing::instrument(skip_all)]
18 pub async fn get_items(
19 &self,
20 page: i64,
21 ) -> Result<PaginatedItems, FeedError> {
22 let offset = page * self.page_size;
23
24 // Fetch page_size + 1 to detect whether more pages exist.
25 let fetch_limit = self.page_size + 1;
26
27 // Push as many filters as possible into SQL so pagination is
28 // accurate. `list_filtered` handles any combination of source,
29 // unread, starred, and full-text search in a single query.
30 let items = self
31 .db
32 .items()
33 .list_filtered(
34 self.filter.search.as_deref(),
35 self.filter.source.as_deref(),
36 self.filter.unread_only,
37 self.filter.starred_only,
38 fetch_limit,
39 offset,
40 )
41 .await?;
42
43 // Convert to FeedItem
44 let mut feed_items: Vec<FeedItem> = items
45 .into_iter()
46 .map(|db_item| db_item.to_feed_item())
47 .collect();
48
49 // Track whether SQL returned a full page (indicating more rows exist)
50 // BEFORE any in-memory filtering reduces the count.
51 let sql_had_more = feed_items.len() > self.page_size as usize;
52
53 // Whether any in-memory filter is active (used for has_more logic below).
54 let needs_inmemory_filter =
55 !self.filter.tags.is_empty() || !self.filter.feed_tags.is_empty() || !self.filter.conditions.is_empty();
56
57 // Apply feed-tag filtering: only keep items whose feed has a matching tag.
58 if !self.filter.feed_tags.is_empty() {
59 let matching_feed_ids = self
60 .db
61 .tags()
62 .feed_ids_with_tags(&self.filter.feed_tags)
63 .await?;
64
65 // Resolve feed_ids to busser_ids so we can filter FeedItems
66 let feeds = self.db.feeds().list_all().await?;
67 let matching_busser_ids: std::collections::HashSet<String> = feeds
68 .iter()
69 .filter(|f| matching_feed_ids.contains(&f.id))
70 .map(|f| f.busser_id.to_string())
71 .collect();
72
73 feed_items.retain(|item| matching_busser_ids.contains(&item.id.source));
74 }
75
76 if !self.filter.tags.is_empty() {
77 feed_items = self.filter.apply_tags_only(feed_items);
78 }
79
80 // Apply query feed conditions that require in-memory evaluation
81 // (title/author/body contains, not_contains, equals, matches_regex).
82 // Pre-compile regexes once to avoid O(N×M) recompilation per item.
83 if !self.filter.conditions.is_empty() {
84 let regex_cache = self.filter.compile_regexes();
85 feed_items.retain(|item| self.filter.matches_with_cache(item, &regex_cache));
86 }
87 self.order_by.apply(&mut feed_items);
88 // When in-memory filtering is active and SQL indicated more rows exist,
89 // we cannot know whether subsequent SQL pages contain matching items.
90 // Conservatively report has_more = true so the UI can request the next
91 // page. This may occasionally produce an empty last page, which is a
92 // better UX than silently hiding matching items.
93 let has_more = if needs_inmemory_filter && sql_had_more && feed_items.len() <= self.page_size as usize {
94 true
95 } else {
96 feed_items.len() > self.page_size as usize
97 };
98 feed_items.truncate(self.page_size as usize);
99
100 debug!(count = feed_items.len(), page, has_more, "Returning items");
101 Ok(PaginatedItems {
102 items: feed_items,
103 has_more,
104 })
105 }
106
107 /// Maximum number of items to fetch in a single `get_all_items` call.
108 /// Guards against unbounded memory usage when the database is large.
109 /// Increase this (or switch to paginated iteration) if feeds grow past
110 /// this threshold.
111 const MAX_ALL_ITEMS: i64 = 10_000;
112
113 /// Get all items (for offline use or small feeds).
114 ///
115 /// Capped at [`Self::MAX_ALL_ITEMS`] rows to prevent unbounded memory use.
116 /// Fetches `MAX_ALL_ITEMS + 1` to detect whether more items exist beyond
117 /// the cap, using the same strategy as [`Self::get_items`].
118 #[tracing::instrument(skip_all)]
119 pub async fn get_all_items(&self) -> Result<PaginatedItems, FeedError> {
120 let fetch_limit = Self::MAX_ALL_ITEMS + 1;
121 // Push source/unread/starred/search filters to SQL for efficiency
122 let items = self
123 .db
124 .items()
125 .list_filtered(
126 self.filter.search.as_deref(),
127 self.filter.source.as_deref(),
128 self.filter.unread_only,
129 self.filter.starred_only,
130 fetch_limit,
131 0,
132 )
133 .await?;
134
135 let mut feed_items: Vec<FeedItem> = items
136 .into_iter()
137 .map(|db_item| db_item.to_feed_item())
138 .collect();
139
140 // Apply feed-tag filtering (same as get_items) before general filter.
141 if !self.filter.feed_tags.is_empty() {
142 let matching_feed_ids = self
143 .db
144 .tags()
145 .feed_ids_with_tags(&self.filter.feed_tags)
146 .await?;
147 let feeds = self.db.feeds().list_all().await?;
148 let matching_busser_ids: std::collections::HashSet<String> = feeds
149 .iter()
150 .filter(|f| matching_feed_ids.contains(&f.id))
151 .map(|f| f.busser_id.to_string())
152 .collect();
153 feed_items.retain(|item| matching_busser_ids.contains(&item.id.source));
154 }
155
156 feed_items = self.filter.apply(feed_items);
157 self.order_by.apply(&mut feed_items);
158
159 let has_more = feed_items.len() > Self::MAX_ALL_ITEMS as usize;
160 feed_items.truncate(Self::MAX_ALL_ITEMS as usize);
161
162 Ok(PaginatedItems {
163 items: feed_items,
164 has_more,
165 })
166 }
167
168 /// Get total item count, respecting source, unread, and starred filters.
169 #[tracing::instrument(skip_all)]
170 pub async fn count(&self) -> Result<i64, FeedError> {
171 Ok(self
172 .db
173 .items()
174 .count_filtered(
175 self.filter.source.as_deref(),
176 self.filter.unread_only,
177 self.filter.starred_only,
178 )
179 .await?)
180 }
181
182 /// Get unread count
183 #[tracing::instrument(skip_all)]
184 pub async fn unread_count(&self) -> Result<i64, FeedError> {
185 Ok(self.db.items().count_unread().await?)
186 }
187
188 /// Get source information.
189 ///
190 /// Fetches all feeds and their item counts in two queries (feeds + a single
191 /// GROUP BY on feed_items) instead of issuing per-source count queries.
192 #[tracing::instrument(skip_all)]
193 pub async fn get_sources(&self) -> Result<Vec<SourceInfo>, FeedError> {
194 let feeds = self.db.feeds().list_all().await?;
195 let counts = self.db.items().counts_by_busser().await?;
196 let all_feed_tags = self.db.tags().all_feed_tags().await?;
197
198 // Build a lookup map: busser_id -> (total, unread)
199 let count_map: std::collections::HashMap<&str, (i64, i64)> = counts
200 .iter()
201 .map(|(id, total, unread)| (id.as_str(), (*total, *unread)))
202 .collect();
203
204 // Build a lookup map: feed_id -> Vec<tag>
205 let mut tag_map: std::collections::HashMap<bb_db::FeedId, Vec<String>> =
206 std::collections::HashMap::new();
207 for (feed_id, tag) in all_feed_tags {
208 tag_map.entry(feed_id).or_default().push(tag);
209 }
210
211 let sources = feeds
212 .into_iter()
213 .map(|feed| {
214 let (total_count, unread_count) =
215 count_map.get(feed.busser_id.as_str()).copied().unwrap_or((0, 0));
216 let tags = tag_map.remove(&feed.id).unwrap_or_default();
217 SourceInfo {
218 id: feed.busser_id.to_string(),
219 name: feed.name,
220 total_count,
221 unread_count,
222 tags,
223 consecutive_failures: feed.consecutive_failures,
224 last_error: feed.last_error,
225 circuit_broken: feed.circuit_broken,
226 }
227 })
228 .collect();
229
230 Ok(sources)
231 }
232 }
233
234 #[cfg(test)]
235 mod tests {
236 use super::super::tests::*;
237 use super::super::FeedGenerator;
238 use crate::{FeedFilter, OrderBy};
239
240 // ── get_items ────────────────────────────────────────────────
241
242 #[tokio::test]
243 async fn get_items_empty_db_returns_empty() {
244 let db = test_db().await;
245 let fg = FeedGenerator::new(db);
246 let result = fg.get_items(0).await.unwrap();
247 assert!(result.items.is_empty());
248 assert!(!result.has_more);
249 }
250
251 #[tokio::test]
252 async fn get_items_returns_inserted_items() {
253 let db = test_db().await;
254 let feed = seed_feed(&db, "rss", "Test Feed").await;
255 seed_item(&db, &feed, "rss:1", 2).await;
256 seed_item(&db, &feed, "rss:2", 1).await;
257
258 let fg = FeedGenerator::new(db);
259 let result = fg.get_items(0).await.unwrap();
260 assert_eq!(result.items.len(), 2);
261 assert!(!result.has_more);
262 }
263
264 #[tokio::test]
265 async fn get_items_respects_page_size() {
266 let db = test_db().await;
267 let feed = seed_feed(&db, "rss", "Feed").await;
268 for i in 0..10 {
269 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
270 }
271
272 let fg = FeedGenerator::new(db).with_page_size(3);
273 let result = fg.get_items(0).await.unwrap();
274 assert_eq!(result.items.len(), 3);
275 assert!(result.has_more);
276 }
277
278 // ── count ────────────────────────────────────────────────────
279
280 #[tokio::test]
281 async fn count_returns_total() {
282 let db = test_db().await;
283 let feed = seed_feed(&db, "rss", "Feed").await;
284 seed_item(&db, &feed, "rss:1", 0).await;
285 seed_item(&db, &feed, "rss:2", 0).await;
286 seed_item(&db, &feed, "rss:3", 0).await;
287
288 let fg = FeedGenerator::new(db);
289 assert_eq!(fg.count().await.unwrap(), 3);
290 }
291
292 #[tokio::test]
293 async fn count_with_source_filter() {
294 let db = test_db().await;
295 let feed_a = seed_feed(&db, "rss", "RSS").await;
296 let feed_b = seed_feed(&db, "hn", "HN").await;
297 seed_item(&db, &feed_a, "rss:1", 0).await;
298 seed_item(&db, &feed_b, "hn:1", 0).await;
299 seed_item(&db, &feed_b, "hn:2", 0).await;
300
301 let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
302 assert_eq!(fg.count().await.unwrap(), 2);
303 }
304
305 // ── get_sources ──────────────────────────────────────────────
306
307 #[tokio::test]
308 async fn get_sources_aggregates_feeds() {
309 let db = test_db().await;
310 let feed_a = seed_feed(&db, "rss", "RSS Feed").await;
311 let feed_b = seed_feed(&db, "hn", "HN Feed").await;
312 seed_item(&db, &feed_a, "rss:1", 0).await;
313 seed_item(&db, &feed_b, "hn:1", 0).await;
314 seed_item(&db, &feed_b, "hn:2", 0).await;
315
316 let fg = FeedGenerator::new(db);
317 let sources = fg.get_sources().await.unwrap();
318 assert_eq!(sources.len(), 2);
319
320 let hn_source = sources.iter().find(|s| s.id == "hn").unwrap();
321 assert_eq!(hn_source.total_count, 2);
322 }
323
324 // ── Pagination edge cases ────────────────────────────────────
325
326 #[tokio::test]
327 async fn get_items_exact_page_boundary_no_has_more() {
328 let db = test_db().await;
329 let feed = seed_feed(&db, "rss", "Feed").await;
330 // Insert exactly page_size items
331 for i in 0..3 {
332 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
333 }
334
335 let fg = FeedGenerator::new(db).with_page_size(3);
336 let result = fg.get_items(0).await.unwrap();
337 assert_eq!(result.items.len(), 3);
338 assert!(!result.has_more);
339 }
340
341 #[tokio::test]
342 async fn get_items_page_boundary_plus_one_has_more() {
343 let db = test_db().await;
344 let feed = seed_feed(&db, "rss", "Feed").await;
345 // Insert page_size + 1 items
346 for i in 0..4 {
347 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
348 }
349
350 let fg = FeedGenerator::new(db).with_page_size(3);
351 let result = fg.get_items(0).await.unwrap();
352 assert_eq!(result.items.len(), 3);
353 assert!(result.has_more);
354 }
355
356 #[tokio::test]
357 async fn get_items_last_page_partial() {
358 let db = test_db().await;
359 let feed = seed_feed(&db, "rss", "Feed").await;
360 for i in 0..5 {
361 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
362 }
363
364 let fg = FeedGenerator::new(db).with_page_size(3);
365 let page1 = fg.get_items(1).await.unwrap();
366 assert_eq!(page1.items.len(), 2);
367 assert!(!page1.has_more);
368 }
369
370 #[tokio::test]
371 async fn get_items_past_end_returns_empty() {
372 let db = test_db().await;
373 let feed = seed_feed(&db, "rss", "Feed").await;
374 seed_item(&db, &feed, "rss:1", 0).await;
375
376 let fg = FeedGenerator::new(db).with_page_size(3);
377 let result = fg.get_items(5).await.unwrap();
378 assert!(result.items.is_empty());
379 assert!(!result.has_more);
380 }
381
382 // ── Feed-tag filtering ──────────────────────────────────────
383
384 #[tokio::test]
385 async fn get_items_feed_tag_filter_keeps_matching() {
386 let db = test_db().await;
387 let feed_a = seed_feed(&db, "rss", "Tech Blog").await;
388 let feed_b = seed_feed(&db, "hn", "News").await;
389 seed_item(&db, &feed_a, "rss:1", 0).await;
390 seed_item(&db, &feed_b, "hn:1", 0).await;
391
392 // Tag only feed_a
393 db.tags()
394 .set_tags(feed_a.id, &["tech".to_string()])
395 .await
396 .unwrap();
397
398 let fg = FeedGenerator::new(db)
399 .with_filter(FeedFilter::new().with_feed_tag("tech"));
400 let result = fg.get_items(0).await.unwrap();
401 assert_eq!(result.items.len(), 1);
402 assert_eq!(result.items[0].id.source, "rss");
403 }
404
405 #[tokio::test]
406 async fn get_items_feed_tag_filter_no_match_returns_empty() {
407 let db = test_db().await;
408 let feed = seed_feed(&db, "rss", "Feed").await;
409 seed_item(&db, &feed, "rss:1", 0).await;
410
411 let fg = FeedGenerator::new(db)
412 .with_filter(FeedFilter::new().with_feed_tag("nonexistent"));
413 let result = fg.get_items(0).await.unwrap();
414 assert!(result.items.is_empty());
415 }
416
417 #[tokio::test]
418 async fn get_items_feed_tag_filter_multiple_feeds_same_tag() {
419 let db = test_db().await;
420 let feed_a = seed_feed(&db, "rss", "Blog A").await;
421 let feed_b = seed_feed(&db, "hn", "Blog B").await;
422 let feed_c = seed_feed(&db, "arxiv", "Science").await;
423 seed_item(&db, &feed_a, "rss:1", 0).await;
424 seed_item(&db, &feed_b, "hn:1", 0).await;
425 seed_item(&db, &feed_c, "arxiv:1", 0).await;
426
427 // Tag feeds A and B with "tech"
428 db.tags()
429 .set_tags(feed_a.id, &["tech".to_string()])
430 .await
431 .unwrap();
432 db.tags()
433 .set_tags(feed_b.id, &["tech".to_string()])
434 .await
435 .unwrap();
436
437 let fg = FeedGenerator::new(db)
438 .with_filter(FeedFilter::new().with_feed_tag("tech"));
439 let result = fg.get_items(0).await.unwrap();
440 assert_eq!(result.items.len(), 2);
441 }
442
443 // ── Filter combinations ─────────────────────────────────────
444
445 #[tokio::test]
446 async fn get_items_unread_filter() {
447 let db = test_db().await;
448 let feed = seed_feed(&db, "rss", "Feed").await;
449 let item1 = seed_item(&db, &feed, "rss:1", 0).await;
450 seed_item(&db, &feed, "rss:2", 1).await;
451
452 db.items().mark_read(item1.id, true).await.unwrap();
453
454 let fg = FeedGenerator::new(db)
455 .with_filter(FeedFilter::new().unread_only());
456 let result = fg.get_items(0).await.unwrap();
457 assert_eq!(result.items.len(), 1);
458 }
459
460 #[tokio::test]
461 async fn get_items_starred_filter() {
462 let db = test_db().await;
463 let feed = seed_feed(&db, "rss", "Feed").await;
464 seed_item(&db, &feed, "rss:1", 0).await;
465 let item2 = seed_item(&db, &feed, "rss:2", 1).await;
466
467 db.items().mark_starred(item2.id, true).await.unwrap();
468
469 let fg = FeedGenerator::new(db)
470 .with_filter(FeedFilter::new().starred_only());
471 let result = fg.get_items(0).await.unwrap();
472 assert_eq!(result.items.len(), 1);
473 assert!(result.items[0].is_starred);
474 }
475
476 // ── get_all_items ───────────────────────────────────────────
477
478 #[tokio::test]
479 async fn get_all_items_returns_all() {
480 let db = test_db().await;
481 let feed = seed_feed(&db, "rss", "Feed").await;
482 for i in 0..5 {
483 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
484 }
485
486 let fg = FeedGenerator::new(db).with_page_size(2); // page_size irrelevant for get_all
487 let result = fg.get_all_items().await.unwrap();
488 assert_eq!(result.items.len(), 5);
489 assert!(!result.has_more);
490 }
491
492 #[tokio::test]
493 async fn get_all_items_applies_filter() {
494 let db = test_db().await;
495 let feed_a = seed_feed(&db, "rss", "RSS").await;
496 let feed_b = seed_feed(&db, "hn", "HN").await;
497 seed_item(&db, &feed_a, "rss:1", 0).await;
498 seed_item(&db, &feed_b, "hn:1", 0).await;
499
500 let fg = FeedGenerator::new(db)
501 .with_filter(FeedFilter::new().source("rss"));
502 let result = fg.get_all_items().await.unwrap();
503 assert_eq!(result.items.len(), 1);
504 assert_eq!(result.items[0].id.source, "rss");
505 }
506
507 #[tokio::test]
508 async fn get_all_items_applies_ordering() {
509 let db = test_db().await;
510 let feed = seed_feed(&db, "rss", "Feed").await;
511 seed_item(&db, &feed, "rss:old", 10).await;
512 seed_item(&db, &feed, "rss:new", 1).await;
513
514 let fg = FeedGenerator::new(db).with_order(OrderBy::Chronological);
515 let result = fg.get_all_items().await.unwrap();
516 assert_eq!(result.items[0].id.item_id, "rss:new");
517 assert_eq!(result.items[1].id.item_id, "rss:old");
518 }
519
520 // ── unread_count ────────────────────────────────────────────
521
522 #[tokio::test]
523 async fn unread_count_all_unread() {
524 let db = test_db().await;
525 let feed = seed_feed(&db, "rss", "Feed").await;
526 seed_item(&db, &feed, "rss:1", 0).await;
527 seed_item(&db, &feed, "rss:2", 0).await;
528
529 let fg = FeedGenerator::new(db);
530 assert_eq!(fg.unread_count().await.unwrap(), 2);
531 }
532
533 #[tokio::test]
534 async fn unread_count_after_mark_read() {
535 let db = test_db().await;
536 let feed = seed_feed(&db, "rss", "Feed").await;
537 let item = seed_item(&db, &feed, "rss:1", 0).await;
538 seed_item(&db, &feed, "rss:2", 0).await;
539
540 db.items().mark_read(item.id, true).await.unwrap();
541
542 let fg = FeedGenerator::new(db);
543 assert_eq!(fg.unread_count().await.unwrap(), 1);
544 }
545
546 // ── count with unread filter ────────────────────────────────
547
548 #[tokio::test]
549 async fn count_with_unread_filter() {
550 let db = test_db().await;
551 let feed = seed_feed(&db, "rss", "Feed").await;
552 let item = seed_item(&db, &feed, "rss:1", 0).await;
553 seed_item(&db, &feed, "rss:2", 0).await;
554 seed_item(&db, &feed, "rss:3", 0).await;
555
556 db.items().mark_read(item.id, true).await.unwrap();
557
558 let fg = FeedGenerator::new(db)
559 .with_filter(FeedFilter::new().unread_only());
560 assert_eq!(fg.count().await.unwrap(), 2);
561 }
562
563 // ── get_sources details ─────────────────────────────────────
564
565 #[tokio::test]
566 async fn get_sources_includes_tags() {
567 let db = test_db().await;
568 let feed = seed_feed(&db, "rss", "Feed").await;
569
570 db.tags()
571 .set_tags(feed.id, &["tech".to_string(), "rust".to_string()])
572 .await
573 .unwrap();
574
575 let fg = FeedGenerator::new(db);
576 let sources = fg.get_sources().await.unwrap();
577 assert_eq!(sources.len(), 1);
578 assert!(sources[0].tags.contains(&"tech".to_string()));
579 assert!(sources[0].tags.contains(&"rust".to_string()));
580 }
581
582 #[tokio::test]
583 async fn get_sources_tracks_unread() {
584 let db = test_db().await;
585 let feed = seed_feed(&db, "rss", "Feed").await;
586 let item = seed_item(&db, &feed, "rss:1", 0).await;
587 seed_item(&db, &feed, "rss:2", 0).await;
588
589 db.items().mark_read(item.id, true).await.unwrap();
590
591 let fg = FeedGenerator::new(db);
592 let sources = fg.get_sources().await.unwrap();
593 assert_eq!(sources[0].total_count, 2);
594 assert_eq!(sources[0].unread_count, 1);
595 }
596
597 #[tokio::test]
598 async fn get_sources_empty_db() {
599 let db = test_db().await;
600 let fg = FeedGenerator::new(db);
601 let sources = fg.get_sources().await.unwrap();
602 assert!(sources.is_empty());
603 }
604
605 // ── In-memory filtering & ordering in get_items ─────────────
606
607 // ── Ordering within get_items ────────────────────────────────
608
609 #[tokio::test]
610 async fn get_items_chronological_ordering() {
611 let db = test_db().await;
612 let feed = seed_feed(&db, "rss", "Feed").await;
613 seed_item(&db, &feed, "rss:old", 10).await;
614 seed_item(&db, &feed, "rss:mid", 5).await;
615 seed_item(&db, &feed, "rss:new", 1).await;
616
617 let fg = FeedGenerator::new(db).with_order(OrderBy::Chronological);
618 let result = fg.get_items(0).await.unwrap();
619 assert_eq!(result.items[0].id.item_id, "rss:new");
620 assert_eq!(result.items[1].id.item_id, "rss:mid");
621 assert_eq!(result.items[2].id.item_id, "rss:old");
622 }
623
624 #[tokio::test]
625 async fn get_items_score_ordering() {
626 let db = test_db().await;
627 let feed = seed_feed(&db, "rss", "Feed").await;
628 seed_scored_item(&db, &feed, "rss:low", 1, Some(10)).await;
629 seed_scored_item(&db, &feed, "rss:high", 2, Some(100)).await;
630 seed_scored_item(&db, &feed, "rss:none", 3, None).await;
631
632 let fg = FeedGenerator::new(db).with_order(OrderBy::Score);
633 let result = fg.get_items(0).await.unwrap();
634 assert_eq!(result.items[0].id.item_id, "rss:high");
635 assert_eq!(result.items[1].id.item_id, "rss:low");
636 assert_eq!(result.items[2].id.item_id, "rss:none");
637 }
638
639 #[tokio::test]
640 async fn get_items_score_tiebreak_by_date() {
641 let db = test_db().await;
642 let feed = seed_feed(&db, "rss", "Feed").await;
643 seed_scored_item(&db, &feed, "rss:old_50", 10, Some(50)).await;
644 seed_scored_item(&db, &feed, "rss:new_50", 1, Some(50)).await;
645
646 let fg = FeedGenerator::new(db).with_order(OrderBy::Score);
647 let result = fg.get_items(0).await.unwrap();
648 // Same score, newer item should come first
649 assert_eq!(result.items[0].id.item_id, "rss:new_50");
650 assert_eq!(result.items[1].id.item_id, "rss:old_50");
651 }
652
653 #[tokio::test]
654 async fn get_items_unread_first_ordering() {
655 let db = test_db().await;
656 let feed = seed_feed(&db, "rss", "Feed").await;
657 let read_item = seed_item(&db, &feed, "rss:read", 1).await;
658 seed_item(&db, &feed, "rss:unread1", 2).await;
659 seed_item(&db, &feed, "rss:unread2", 3).await;
660
661 db.items().mark_read(read_item.id, true).await.unwrap();
662
663 let fg = FeedGenerator::new(db).with_order(OrderBy::UnreadFirst);
664 let result = fg.get_items(0).await.unwrap();
665 assert_eq!(result.items.len(), 3);
666 // Items should be grouped by read status: the sort uses
667 // b.is_read.cmp(&a.is_read) which groups items by read state,
668 // with chronological tiebreak within each group.
669 let read_states: Vec<bool> = result.items.iter().map(|i| i.is_read).collect();
670 // Verify grouping: all items of one read-state come before the other
671 let first_state = read_states[0];
672 let boundary = read_states.iter().position(|&r| r != first_state);
673 if let Some(b) = boundary {
674 assert!(read_states[b..].iter().all(|&r| r != first_state),
675 "read states should be grouped, not interleaved");
676 }
677 }
678
679 #[tokio::test]
680 async fn get_items_starred_first_ordering() {
681 let db = test_db().await;
682 let feed = seed_feed(&db, "rss", "Feed").await;
683 seed_item(&db, &feed, "rss:normal", 1).await;
684 let starred_item = seed_item(&db, &feed, "rss:starred", 2).await;
685
686 db.items().mark_starred(starred_item.id, true).await.unwrap();
687
688 let fg = FeedGenerator::new(db).with_order(OrderBy::StarredFirst);
689 let result = fg.get_items(0).await.unwrap();
690 assert!(result.items[0].is_starred, "first item should be starred");
691 assert!(!result.items[1].is_starred, "second item should not be starred");
692 }
693
694 // ── Item-level tag filtering within get_items ────────────────
695
696 #[tokio::test]
697 async fn get_items_tag_filter_keeps_matching() {
698 let db = test_db().await;
699 let feed = seed_feed(&db, "rss", "Feed").await;
700 seed_tagged_item(&db, &feed, "rss:rust", 1, vec!["rust".into()]).await;
701 seed_tagged_item(&db, &feed, "rss:python", 2, vec!["python".into()]).await;
702 seed_tagged_item(&db, &feed, "rss:both", 3, vec!["rust".into(), "go".into()]).await;
703
704 let fg = FeedGenerator::new(db)
705 .with_filter(FeedFilter::new().with_tag("rust"));
706 let result = fg.get_items(0).await.unwrap();
707 assert_eq!(result.items.len(), 2);
708 let ids: Vec<&str> = result.items.iter().map(|i| i.id.item_id.as_str()).collect();
709 assert!(ids.contains(&"rss:rust"));
710 assert!(ids.contains(&"rss:both"));
711 }
712
713 #[tokio::test]
714 async fn get_items_tag_filter_no_match_returns_empty() {
715 let db = test_db().await;
716 let feed = seed_feed(&db, "rss", "Feed").await;
717 seed_tagged_item(&db, &feed, "rss:1", 1, vec!["python".into()]).await;
718
719 let fg = FeedGenerator::new(db)
720 .with_filter(FeedFilter::new().with_tag("rust"));
721 let result = fg.get_items(0).await.unwrap();
722 assert!(result.items.is_empty());
723 }
724
725 #[tokio::test]
726 async fn get_items_tag_filter_or_logic() {
727 let db = test_db().await;
728 let feed = seed_feed(&db, "rss", "Feed").await;
729 seed_tagged_item(&db, &feed, "rss:r", 1, vec!["rust".into()]).await;
730 seed_tagged_item(&db, &feed, "rss:g", 2, vec!["go".into()]).await;
731 seed_tagged_item(&db, &feed, "rss:p", 3, vec!["python".into()]).await;
732
733 // Filter with two tags -- OR logic: items matching either tag pass
734 let fg = FeedGenerator::new(db)
735 .with_filter(FeedFilter::new().with_tag("rust").with_tag("go"));
736 let result = fg.get_items(0).await.unwrap();
737 assert_eq!(result.items.len(), 2);
738 }
739
740 #[tokio::test]
741 async fn get_items_tag_filter_items_with_no_tags_excluded() {
742 let db = test_db().await;
743 let feed = seed_feed(&db, "rss", "Feed").await;
744 seed_item(&db, &feed, "rss:notagged", 1).await; // No tags
745 seed_tagged_item(&db, &feed, "rss:tagged", 2, vec!["rust".into()]).await;
746
747 let fg = FeedGenerator::new(db)
748 .with_filter(FeedFilter::new().with_tag("rust"));
749 let result = fg.get_items(0).await.unwrap();
750 assert_eq!(result.items.len(), 1);
751 assert_eq!(result.items[0].id.item_id, "rss:tagged");
752 }
753
754 // ── has_more correctness after in-memory tag filtering ──────
755
756 #[tokio::test]
757 async fn has_more_true_when_inmemory_filter_reduces_below_page_size_but_sql_had_more() {
758 let db = test_db().await;
759 let feed = seed_feed(&db, "rss", "Feed").await;
760 // Create page_size+1 items (5 items, page_size=3), but only 1 has
761 // the matching tag. SQL returns 4 items (page_size+1), in-memory
762 // filtering reduces to 1 item. Since SQL had more rows and in-memory
763 // filtering is active, has_more should be true (there might be
764 // matching items on subsequent SQL pages).
765 seed_tagged_item(&db, &feed, "rss:yes", 0, vec!["rust".into()]).await;
766 for i in 1..=4 {
767 seed_tagged_item(&db, &feed, &format!("rss:no_{i}"), i as i64, vec!["python".into()]).await;
768 }
769
770 let fg = FeedGenerator::new(db)
771 .with_page_size(3)
772 .with_filter(FeedFilter::new().with_tag("rust"));
773 let result = fg.get_items(0).await.unwrap();
774 assert_eq!(result.items.len(), 1);
775 // SQL returned 4 items (> page_size=3), so sql_had_more=true.
776 // After in-memory tag filtering, only 1 item remains (< page_size).
777 // Conservatively report has_more=true since more matching items
778 // may exist on later SQL pages.
779 assert!(result.has_more);
780 }
781
782 #[tokio::test]
783 async fn has_more_false_when_no_inmemory_filter_and_few_items() {
784 let db = test_db().await;
785 let feed = seed_feed(&db, "rss", "Feed").await;
786 // Only 2 items, page_size=3, no in-memory filters.
787 seed_item(&db, &feed, "rss:1", 0).await;
788 seed_item(&db, &feed, "rss:2", 1).await;
789
790 let fg = FeedGenerator::new(db).with_page_size(3);
791 let result = fg.get_items(0).await.unwrap();
792 assert_eq!(result.items.len(), 2);
793 assert!(!result.has_more);
794 }
795
796 #[tokio::test]
797 async fn has_more_false_when_all_items_match_filter_and_fit_page() {
798 let db = test_db().await;
799 let feed = seed_feed(&db, "rss", "Feed").await;
800 // Only 2 items, both match tag, page_size=3. SQL returns 2 (< page_size+1),
801 // so sql_had_more=false. has_more should be false.
802 seed_tagged_item(&db, &feed, "rss:1", 0, vec!["rust".into()]).await;
803 seed_tagged_item(&db, &feed, "rss:2", 1, vec!["rust".into()]).await;
804
805 let fg = FeedGenerator::new(db)
806 .with_page_size(3)
807 .with_filter(FeedFilter::new().with_tag("rust"));
808 let result = fg.get_items(0).await.unwrap();
809 assert_eq!(result.items.len(), 2);
810 assert!(!result.has_more);
811 }
812
813 // ── Source filter within get_items ───────────────────────────
814
815 #[tokio::test]
816 async fn get_items_source_filter() {
817 let db = test_db().await;
818 let feed_a = seed_feed(&db, "rss", "RSS").await;
819 let feed_b = seed_feed(&db, "hn", "HN").await;
820 seed_item(&db, &feed_a, "rss:1", 1).await;
821 seed_item(&db, &feed_a, "rss:2", 2).await;
822 seed_item(&db, &feed_b, "hn:1", 3).await;
823
824 let fg = FeedGenerator::new(db)
825 .with_filter(FeedFilter::new().source("rss"));
826 let result = fg.get_items(0).await.unwrap();
827 assert_eq!(result.items.len(), 2);
828 assert!(result.items.iter().all(|i| i.id.source == "rss"));
829 }
830
831 // ── Search filter within get_items ──────────────────────────
832
833 #[tokio::test]
834 async fn get_items_search_filter_matches_title() {
835 let db = test_db().await;
836 let feed = seed_feed(&db, "rss", "Feed").await;
837 seed_item(&db, &feed, "rss:1", 1).await; // Title: "Title rss:1"
838 seed_item(&db, &feed, "rss:2", 2).await; // Title: "Title rss:2"
839
840 let fg = FeedGenerator::new(db)
841 .with_filter(FeedFilter::new().search("Title rss:1"));
842 let result = fg.get_items(0).await.unwrap();
843 assert_eq!(result.items.len(), 1);
844 assert_eq!(result.items[0].id.item_id, "rss:1");
845 }
846
847 #[tokio::test]
848 async fn get_items_search_filter_no_match() {
849 let db = test_db().await;
850 let feed = seed_feed(&db, "rss", "Feed").await;
851 seed_item(&db, &feed, "rss:1", 1).await;
852
853 let fg = FeedGenerator::new(db)
854 .with_filter(FeedFilter::new().search("zzz_nonexistent_zzz"));
855 let result = fg.get_items(0).await.unwrap();
856 assert!(result.items.is_empty());
857 }
858
859 // ── Combined filters within get_items ───────────────────────
860
861 #[tokio::test]
862 async fn get_items_search_with_source_filter() {
863 let db = test_db().await;
864 let feed_a = seed_feed(&db, "rss", "RSS").await;
865 let feed_b = seed_feed(&db, "hn", "HN").await;
866 seed_item(&db, &feed_a, "rss:match", 1).await;
867 seed_item(&db, &feed_b, "hn:match", 2).await;
868
869 // Search that matches both, but restricted to rss source
870 let fg = FeedGenerator::new(db)
871 .with_filter(FeedFilter::new().search("Item").source("rss"));
872 let result = fg.get_items(0).await.unwrap();
873 assert_eq!(result.items.len(), 1);
874 assert_eq!(result.items[0].id.source, "rss");
875 }
876
877 #[tokio::test]
878 async fn get_items_search_with_unread_filter() {
879 let db = test_db().await;
880 let feed = seed_feed(&db, "rss", "Feed").await;
881 let item1 = seed_item(&db, &feed, "rss:1", 1).await;
882 seed_item(&db, &feed, "rss:2", 2).await;
883
884 db.items().mark_read(item1.id, true).await.unwrap();
885
886 // Search matches both items, but only unread should appear
887 let fg = FeedGenerator::new(db)
888 .with_filter(FeedFilter::new().search("Item").unread_only());
889 let result = fg.get_items(0).await.unwrap();
890 assert_eq!(result.items.len(), 1);
891 assert!(!result.items[0].is_read);
892 }
893
894 #[tokio::test]
895 async fn get_items_search_with_starred_filter() {
896 let db = test_db().await;
897 let feed = seed_feed(&db, "rss", "Feed").await;
898 seed_item(&db, &feed, "rss:1", 1).await;
899 let item2 = seed_item(&db, &feed, "rss:2", 2).await;
900
901 db.items().mark_starred(item2.id, true).await.unwrap();
902
903 let fg = FeedGenerator::new(db)
904 .with_filter(FeedFilter::new().search("Item").starred_only());
905 let result = fg.get_items(0).await.unwrap();
906 assert_eq!(result.items.len(), 1);
907 assert!(result.items[0].is_starred);
908 }
909
910 // ── get_all_items additional coverage ────────────────────────
911
912 #[tokio::test]
913 async fn get_all_items_empty_db() {
914 let db = test_db().await;
915 let fg = FeedGenerator::new(db);
916 let result = fg.get_all_items().await.unwrap();
917 assert!(result.items.is_empty());
918 assert!(!result.has_more);
919 }
920
921 #[tokio::test]
922 async fn get_all_items_unread_filter() {
923 let db = test_db().await;
924 let feed = seed_feed(&db, "rss", "Feed").await;
925 let item1 = seed_item(&db, &feed, "rss:1", 1).await;
926 seed_item(&db, &feed, "rss:2", 2).await;
927 seed_item(&db, &feed, "rss:3", 3).await;
928
929 db.items().mark_read(item1.id, true).await.unwrap();
930
931 let fg = FeedGenerator::new(db)
932 .with_filter(FeedFilter::new().unread_only());
933 let result = fg.get_all_items().await.unwrap();
934 assert_eq!(result.items.len(), 2);
935 assert!(result.items.iter().all(|i| !i.is_read));
936 }
937
938 #[tokio::test]
939 async fn get_all_items_starred_filter() {
940 let db = test_db().await;
941 let feed = seed_feed(&db, "rss", "Feed").await;
942 seed_item(&db, &feed, "rss:1", 1).await;
943 let item2 = seed_item(&db, &feed, "rss:2", 2).await;
944
945 db.items().mark_starred(item2.id, true).await.unwrap();
946
947 let fg = FeedGenerator::new(db)
948 .with_filter(FeedFilter::new().starred_only());
949 let result = fg.get_all_items().await.unwrap();
950 assert_eq!(result.items.len(), 1);
951 assert!(result.items[0].is_starred);
952 }
953
954 #[tokio::test]
955 async fn get_all_items_tag_filter() {
956 let db = test_db().await;
957 let feed = seed_feed(&db, "rss", "Feed").await;
958 seed_tagged_item(&db, &feed, "rss:tagged", 1, vec!["rust".into()]).await;
959 seed_item(&db, &feed, "rss:plain", 2).await;
960
961 let fg = FeedGenerator::new(db)
962 .with_filter(FeedFilter::new().with_tag("rust"));
963 let result = fg.get_all_items().await.unwrap();
964 assert_eq!(result.items.len(), 1);
965 assert_eq!(result.items[0].id.item_id, "rss:tagged");
966 }
967
968 #[tokio::test]
969 async fn get_all_items_search_filter() {
970 let db = test_db().await;
971 let feed = seed_feed(&db, "rss", "Feed").await;
972 seed_item(&db, &feed, "rss:1", 1).await;
973 seed_item(&db, &feed, "rss:2", 2).await;
974
975 let fg = FeedGenerator::new(db)
976 .with_filter(FeedFilter::new().search("Item rss:1"));
977 let result = fg.get_all_items().await.unwrap();
978 assert_eq!(result.items.len(), 1);
979 }
980
981 #[tokio::test]
982 async fn get_all_items_combined_source_and_unread() {
983 let db = test_db().await;
984 let feed_a = seed_feed(&db, "rss", "RSS").await;
985 let feed_b = seed_feed(&db, "hn", "HN").await;
986 let item_a1 = seed_item(&db, &feed_a, "rss:1", 1).await;
987 seed_item(&db, &feed_a, "rss:2", 2).await;
988 seed_item(&db, &feed_b, "hn:1", 3).await;
989
990 db.items().mark_read(item_a1.id, true).await.unwrap();
991
992 let fg = FeedGenerator::new(db)
993 .with_filter(FeedFilter::new().source("rss").unread_only());
994 let result = fg.get_all_items().await.unwrap();
995 assert_eq!(result.items.len(), 1);
996 assert_eq!(result.items[0].id.source, "rss");
997 assert!(!result.items[0].is_read);
998 }
999
1000 #[tokio::test]
1001 async fn get_all_items_score_ordering() {
1002 let db = test_db().await;
1003 let feed = seed_feed(&db, "rss", "Feed").await;
1004 seed_scored_item(&db, &feed, "rss:low", 1, Some(5)).await;
1005 seed_scored_item(&db, &feed, "rss:high", 2, Some(500)).await;
1006 seed_scored_item(&db, &feed, "rss:mid", 3, Some(50)).await;
1007
1008 let fg = FeedGenerator::new(db).with_order(OrderBy::Score);
1009 let result = fg.get_all_items().await.unwrap();
1010 assert_eq!(result.items[0].id.item_id, "rss:high");
1011 assert_eq!(result.items[1].id.item_id, "rss:mid");
1012 assert_eq!(result.items[2].id.item_id, "rss:low");
1013 }
1014
1015 // ── get_sources edge cases ──────────────────────────────────
1016
1017 #[tokio::test]
1018 async fn get_sources_feed_with_no_items() {
1019 let db = test_db().await;
1020 seed_feed(&db, "rss", "Empty Feed").await;
1021
1022 let fg = FeedGenerator::new(db);
1023 let sources = fg.get_sources().await.unwrap();
1024 assert_eq!(sources.len(), 1);
1025 assert_eq!(sources[0].total_count, 0);
1026 assert_eq!(sources[0].unread_count, 0);
1027 }
1028
1029 #[tokio::test]
1030 async fn get_sources_multiple_feeds_correct_counts() {
1031 let db = test_db().await;
1032 let feed_a = seed_feed(&db, "rss", "RSS").await;
1033 let feed_b = seed_feed(&db, "hn", "HN").await;
1034 seed_item(&db, &feed_a, "rss:1", 1).await;
1035 seed_item(&db, &feed_a, "rss:2", 2).await;
1036 seed_item(&db, &feed_a, "rss:3", 3).await;
1037 let hn_item = seed_item(&db, &feed_b, "hn:1", 4).await;
1038
1039 db.items().mark_read(hn_item.id, true).await.unwrap();
1040
1041 let fg = FeedGenerator::new(db);
1042 let sources = fg.get_sources().await.unwrap();
1043
1044 let rss = sources.iter().find(|s| s.id == "rss").unwrap();
1045 assert_eq!(rss.total_count, 3);
1046 assert_eq!(rss.unread_count, 3);
1047
1048 let hn = sources.iter().find(|s| s.id == "hn").unwrap();
1049 assert_eq!(hn.total_count, 1);
1050 assert_eq!(hn.unread_count, 0);
1051 }
1052 }
1053