//! Query and pagination methods for the feed generator. use bb_interface::FeedItem; use tracing::debug; use super::{FeedError, FeedGenerator, PaginatedItems, SourceInfo}; impl FeedGenerator { /// Get a page of feed items with pagination metadata. /// /// When a search query is present, the search predicate is pushed into SQL /// so that LIMIT/OFFSET pagination works correctly with filtered results. /// Other filters (source, unread, starred) are combined in the same query. /// /// Fetches `page_size + 1` items to detect whether more pages exist, /// then applies in-memory ordering before truncating to the exact page size. #[tracing::instrument(skip_all)] pub async fn get_items( &self, page: i64, ) -> Result { let offset = page * self.page_size; // Fetch page_size + 1 to detect whether more pages exist. let fetch_limit = self.page_size + 1; // Push as many filters as possible into SQL so pagination is // accurate. `list_filtered` handles any combination of source, // unread, starred, and full-text search in a single query. let items = self .db .items() .list_filtered( self.filter.search.as_deref(), self.filter.source.as_deref(), self.filter.unread_only, self.filter.starred_only, fetch_limit, offset, ) .await?; // Convert to FeedItem let mut feed_items: Vec = items .into_iter() .map(|db_item| db_item.to_feed_item()) .collect(); // Track whether SQL returned a full page (indicating more rows exist) // BEFORE any in-memory filtering reduces the count. let sql_had_more = feed_items.len() > self.page_size as usize; // Whether any in-memory filter is active (used for has_more logic below). let needs_inmemory_filter = !self.filter.tags.is_empty() || !self.filter.feed_tags.is_empty() || !self.filter.conditions.is_empty(); // Apply feed-tag filtering: only keep items whose feed has a matching tag. if !self.filter.feed_tags.is_empty() { let matching_feed_ids = self .db .tags() .feed_ids_with_tags(&self.filter.feed_tags) .await?; // Resolve feed_ids to busser_ids so we can filter FeedItems let feeds = self.db.feeds().list_all().await?; let matching_busser_ids: std::collections::HashSet = feeds .iter() .filter(|f| matching_feed_ids.contains(&f.id)) .map(|f| f.busser_id.to_string()) .collect(); feed_items.retain(|item| matching_busser_ids.contains(&item.id.source)); } if !self.filter.tags.is_empty() { feed_items = self.filter.apply_tags_only(feed_items); } // Apply query feed conditions that require in-memory evaluation // (title/author/body contains, not_contains, equals, matches_regex). // Pre-compile regexes once to avoid O(N×M) recompilation per item. if !self.filter.conditions.is_empty() { let regex_cache = self.filter.compile_regexes(); feed_items.retain(|item| self.filter.matches_with_cache(item, ®ex_cache)); } self.order_by.apply(&mut feed_items); // When in-memory filtering is active and SQL indicated more rows exist, // we cannot know whether subsequent SQL pages contain matching items. // Conservatively report has_more = true so the UI can request the next // page. This may occasionally produce an empty last page, which is a // better UX than silently hiding matching items. let has_more = if needs_inmemory_filter && sql_had_more && feed_items.len() <= self.page_size as usize { true } else { feed_items.len() > self.page_size as usize }; feed_items.truncate(self.page_size as usize); debug!(count = feed_items.len(), page, has_more, "Returning items"); Ok(PaginatedItems { items: feed_items, has_more, }) } /// Maximum number of items to fetch in a single `get_all_items` call. /// Guards against unbounded memory usage when the database is large. /// Increase this (or switch to paginated iteration) if feeds grow past /// this threshold. const MAX_ALL_ITEMS: i64 = 10_000; /// Get all items (for offline use or small feeds). /// /// Capped at [`Self::MAX_ALL_ITEMS`] rows to prevent unbounded memory use. /// Fetches `MAX_ALL_ITEMS + 1` to detect whether more items exist beyond /// the cap, using the same strategy as [`Self::get_items`]. #[tracing::instrument(skip_all)] pub async fn get_all_items(&self) -> Result { let fetch_limit = Self::MAX_ALL_ITEMS + 1; // Push source/unread/starred/search filters to SQL for efficiency let items = self .db .items() .list_filtered( self.filter.search.as_deref(), self.filter.source.as_deref(), self.filter.unread_only, self.filter.starred_only, fetch_limit, 0, ) .await?; let mut feed_items: Vec = items .into_iter() .map(|db_item| db_item.to_feed_item()) .collect(); // Apply feed-tag filtering (same as get_items) before general filter. if !self.filter.feed_tags.is_empty() { let matching_feed_ids = self .db .tags() .feed_ids_with_tags(&self.filter.feed_tags) .await?; let feeds = self.db.feeds().list_all().await?; let matching_busser_ids: std::collections::HashSet = feeds .iter() .filter(|f| matching_feed_ids.contains(&f.id)) .map(|f| f.busser_id.to_string()) .collect(); feed_items.retain(|item| matching_busser_ids.contains(&item.id.source)); } feed_items = self.filter.apply(feed_items); self.order_by.apply(&mut feed_items); let has_more = feed_items.len() > Self::MAX_ALL_ITEMS as usize; feed_items.truncate(Self::MAX_ALL_ITEMS as usize); Ok(PaginatedItems { items: feed_items, has_more, }) } /// Get total item count, respecting source, unread, and starred filters. #[tracing::instrument(skip_all)] pub async fn count(&self) -> Result { Ok(self .db .items() .count_filtered( self.filter.source.as_deref(), self.filter.unread_only, self.filter.starred_only, ) .await?) } /// Get unread count #[tracing::instrument(skip_all)] pub async fn unread_count(&self) -> Result { Ok(self.db.items().count_unread().await?) } /// Get source information. /// /// Fetches all feeds and their item counts in two queries (feeds + a single /// GROUP BY on feed_items) instead of issuing per-source count queries. #[tracing::instrument(skip_all)] pub async fn get_sources(&self) -> Result, FeedError> { let feeds = self.db.feeds().list_all().await?; let counts = self.db.items().counts_by_busser().await?; let all_feed_tags = self.db.tags().all_feed_tags().await?; // Build a lookup map: busser_id -> (total, unread) let count_map: std::collections::HashMap<&str, (i64, i64)> = counts .iter() .map(|(id, total, unread)| (id.as_str(), (*total, *unread))) .collect(); // Build a lookup map: feed_id -> Vec let mut tag_map: std::collections::HashMap> = std::collections::HashMap::new(); for (feed_id, tag) in all_feed_tags { tag_map.entry(feed_id).or_default().push(tag); } let sources = feeds .into_iter() .map(|feed| { let (total_count, unread_count) = count_map.get(feed.busser_id.as_str()).copied().unwrap_or((0, 0)); let tags = tag_map.remove(&feed.id).unwrap_or_default(); SourceInfo { id: feed.busser_id.to_string(), name: feed.name, total_count, unread_count, tags, consecutive_failures: feed.consecutive_failures, last_error: feed.last_error, circuit_broken: feed.circuit_broken, } }) .collect(); Ok(sources) } } #[cfg(test)] mod tests { use super::super::tests::*; use super::super::FeedGenerator; use crate::{FeedFilter, OrderBy}; // ── get_items ──────────────────────────────────────────────── #[tokio::test] async fn get_items_empty_db_returns_empty() { let db = test_db().await; let fg = FeedGenerator::new(db); let result = fg.get_items(0).await.unwrap(); assert!(result.items.is_empty()); assert!(!result.has_more); } #[tokio::test] async fn get_items_returns_inserted_items() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Test Feed").await; seed_item(&db, &feed, "rss:1", 2).await; seed_item(&db, &feed, "rss:2", 1).await; let fg = FeedGenerator::new(db); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); assert!(!result.has_more); } #[tokio::test] async fn get_items_respects_page_size() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; for i in 0..10 { seed_item(&db, &feed, &format!("rss:{i}"), i).await; } let fg = FeedGenerator::new(db).with_page_size(3); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 3); assert!(result.has_more); } // ── count ──────────────────────────────────────────────────── #[tokio::test] async fn count_returns_total() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 0).await; seed_item(&db, &feed, "rss:2", 0).await; seed_item(&db, &feed, "rss:3", 0).await; let fg = FeedGenerator::new(db); assert_eq!(fg.count().await.unwrap(), 3); } #[tokio::test] async fn count_with_source_filter() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "RSS").await; let feed_b = seed_feed(&db, "hn", "HN").await; seed_item(&db, &feed_a, "rss:1", 0).await; seed_item(&db, &feed_b, "hn:1", 0).await; seed_item(&db, &feed_b, "hn:2", 0).await; let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn")); assert_eq!(fg.count().await.unwrap(), 2); } // ── get_sources ────────────────────────────────────────────── #[tokio::test] async fn get_sources_aggregates_feeds() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "RSS Feed").await; let feed_b = seed_feed(&db, "hn", "HN Feed").await; seed_item(&db, &feed_a, "rss:1", 0).await; seed_item(&db, &feed_b, "hn:1", 0).await; seed_item(&db, &feed_b, "hn:2", 0).await; let fg = FeedGenerator::new(db); let sources = fg.get_sources().await.unwrap(); assert_eq!(sources.len(), 2); let hn_source = sources.iter().find(|s| s.id == "hn").unwrap(); assert_eq!(hn_source.total_count, 2); } // ── Pagination edge cases ──────────────────────────────────── #[tokio::test] async fn get_items_exact_page_boundary_no_has_more() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; // Insert exactly page_size items for i in 0..3 { seed_item(&db, &feed, &format!("rss:{i}"), i).await; } let fg = FeedGenerator::new(db).with_page_size(3); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 3); assert!(!result.has_more); } #[tokio::test] async fn get_items_page_boundary_plus_one_has_more() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; // Insert page_size + 1 items for i in 0..4 { seed_item(&db, &feed, &format!("rss:{i}"), i).await; } let fg = FeedGenerator::new(db).with_page_size(3); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 3); assert!(result.has_more); } #[tokio::test] async fn get_items_last_page_partial() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; for i in 0..5 { seed_item(&db, &feed, &format!("rss:{i}"), i).await; } let fg = FeedGenerator::new(db).with_page_size(3); let page1 = fg.get_items(1).await.unwrap(); assert_eq!(page1.items.len(), 2); assert!(!page1.has_more); } #[tokio::test] async fn get_items_past_end_returns_empty() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 0).await; let fg = FeedGenerator::new(db).with_page_size(3); let result = fg.get_items(5).await.unwrap(); assert!(result.items.is_empty()); assert!(!result.has_more); } // ── Feed-tag filtering ────────────────────────────────────── #[tokio::test] async fn get_items_feed_tag_filter_keeps_matching() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "Tech Blog").await; let feed_b = seed_feed(&db, "hn", "News").await; seed_item(&db, &feed_a, "rss:1", 0).await; seed_item(&db, &feed_b, "hn:1", 0).await; // Tag only feed_a db.tags() .set_tags(feed_a.id, &["tech".to_string()]) .await .unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_feed_tag("tech")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.source, "rss"); } #[tokio::test] async fn get_items_feed_tag_filter_no_match_returns_empty() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 0).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_feed_tag("nonexistent")); let result = fg.get_items(0).await.unwrap(); assert!(result.items.is_empty()); } #[tokio::test] async fn get_items_feed_tag_filter_multiple_feeds_same_tag() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "Blog A").await; let feed_b = seed_feed(&db, "hn", "Blog B").await; let feed_c = seed_feed(&db, "arxiv", "Science").await; seed_item(&db, &feed_a, "rss:1", 0).await; seed_item(&db, &feed_b, "hn:1", 0).await; seed_item(&db, &feed_c, "arxiv:1", 0).await; // Tag feeds A and B with "tech" db.tags() .set_tags(feed_a.id, &["tech".to_string()]) .await .unwrap(); db.tags() .set_tags(feed_b.id, &["tech".to_string()]) .await .unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_feed_tag("tech")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); } // ── Filter combinations ───────────────────────────────────── #[tokio::test] async fn get_items_unread_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; let item1 = seed_item(&db, &feed, "rss:1", 0).await; seed_item(&db, &feed, "rss:2", 1).await; db.items().mark_read(item1.id, true).await.unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().unread_only()); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); } #[tokio::test] async fn get_items_starred_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 0).await; let item2 = seed_item(&db, &feed, "rss:2", 1).await; db.items().mark_starred(item2.id, true).await.unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().starred_only()); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert!(result.items[0].is_starred); } // ── get_all_items ─────────────────────────────────────────── #[tokio::test] async fn get_all_items_returns_all() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; for i in 0..5 { seed_item(&db, &feed, &format!("rss:{i}"), i).await; } let fg = FeedGenerator::new(db).with_page_size(2); // page_size irrelevant for get_all let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items.len(), 5); assert!(!result.has_more); } #[tokio::test] async fn get_all_items_applies_filter() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "RSS").await; let feed_b = seed_feed(&db, "hn", "HN").await; seed_item(&db, &feed_a, "rss:1", 0).await; seed_item(&db, &feed_b, "hn:1", 0).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().source("rss")); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.source, "rss"); } #[tokio::test] async fn get_all_items_applies_ordering() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:old", 10).await; seed_item(&db, &feed, "rss:new", 1).await; let fg = FeedGenerator::new(db).with_order(OrderBy::Chronological); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items[0].id.item_id, "rss:new"); assert_eq!(result.items[1].id.item_id, "rss:old"); } // ── unread_count ──────────────────────────────────────────── #[tokio::test] async fn unread_count_all_unread() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 0).await; seed_item(&db, &feed, "rss:2", 0).await; let fg = FeedGenerator::new(db); assert_eq!(fg.unread_count().await.unwrap(), 2); } #[tokio::test] async fn unread_count_after_mark_read() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; let item = seed_item(&db, &feed, "rss:1", 0).await; seed_item(&db, &feed, "rss:2", 0).await; db.items().mark_read(item.id, true).await.unwrap(); let fg = FeedGenerator::new(db); assert_eq!(fg.unread_count().await.unwrap(), 1); } // ── count with unread filter ──────────────────────────────── #[tokio::test] async fn count_with_unread_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; let item = seed_item(&db, &feed, "rss:1", 0).await; seed_item(&db, &feed, "rss:2", 0).await; seed_item(&db, &feed, "rss:3", 0).await; db.items().mark_read(item.id, true).await.unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().unread_only()); assert_eq!(fg.count().await.unwrap(), 2); } // ── get_sources details ───────────────────────────────────── #[tokio::test] async fn get_sources_includes_tags() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; db.tags() .set_tags(feed.id, &["tech".to_string(), "rust".to_string()]) .await .unwrap(); let fg = FeedGenerator::new(db); let sources = fg.get_sources().await.unwrap(); assert_eq!(sources.len(), 1); assert!(sources[0].tags.contains(&"tech".to_string())); assert!(sources[0].tags.contains(&"rust".to_string())); } #[tokio::test] async fn get_sources_tracks_unread() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; let item = seed_item(&db, &feed, "rss:1", 0).await; seed_item(&db, &feed, "rss:2", 0).await; db.items().mark_read(item.id, true).await.unwrap(); let fg = FeedGenerator::new(db); let sources = fg.get_sources().await.unwrap(); assert_eq!(sources[0].total_count, 2); assert_eq!(sources[0].unread_count, 1); } #[tokio::test] async fn get_sources_empty_db() { let db = test_db().await; let fg = FeedGenerator::new(db); let sources = fg.get_sources().await.unwrap(); assert!(sources.is_empty()); } // ── In-memory filtering & ordering in get_items ───────────── // ── Ordering within get_items ──────────────────────────────── #[tokio::test] async fn get_items_chronological_ordering() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:old", 10).await; seed_item(&db, &feed, "rss:mid", 5).await; seed_item(&db, &feed, "rss:new", 1).await; let fg = FeedGenerator::new(db).with_order(OrderBy::Chronological); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items[0].id.item_id, "rss:new"); assert_eq!(result.items[1].id.item_id, "rss:mid"); assert_eq!(result.items[2].id.item_id, "rss:old"); } #[tokio::test] async fn get_items_score_ordering() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_scored_item(&db, &feed, "rss:low", 1, Some(10)).await; seed_scored_item(&db, &feed, "rss:high", 2, Some(100)).await; seed_scored_item(&db, &feed, "rss:none", 3, None).await; let fg = FeedGenerator::new(db).with_order(OrderBy::Score); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items[0].id.item_id, "rss:high"); assert_eq!(result.items[1].id.item_id, "rss:low"); assert_eq!(result.items[2].id.item_id, "rss:none"); } #[tokio::test] async fn get_items_score_tiebreak_by_date() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_scored_item(&db, &feed, "rss:old_50", 10, Some(50)).await; seed_scored_item(&db, &feed, "rss:new_50", 1, Some(50)).await; let fg = FeedGenerator::new(db).with_order(OrderBy::Score); let result = fg.get_items(0).await.unwrap(); // Same score, newer item should come first assert_eq!(result.items[0].id.item_id, "rss:new_50"); assert_eq!(result.items[1].id.item_id, "rss:old_50"); } #[tokio::test] async fn get_items_unread_first_ordering() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; let read_item = seed_item(&db, &feed, "rss:read", 1).await; seed_item(&db, &feed, "rss:unread1", 2).await; seed_item(&db, &feed, "rss:unread2", 3).await; db.items().mark_read(read_item.id, true).await.unwrap(); let fg = FeedGenerator::new(db).with_order(OrderBy::UnreadFirst); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 3); // Items should be grouped by read status: the sort uses // b.is_read.cmp(&a.is_read) which groups items by read state, // with chronological tiebreak within each group. let read_states: Vec = result.items.iter().map(|i| i.is_read).collect(); // Verify grouping: all items of one read-state come before the other let first_state = read_states[0]; let boundary = read_states.iter().position(|&r| r != first_state); if let Some(b) = boundary { assert!(read_states[b..].iter().all(|&r| r != first_state), "read states should be grouped, not interleaved"); } } #[tokio::test] async fn get_items_starred_first_ordering() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:normal", 1).await; let starred_item = seed_item(&db, &feed, "rss:starred", 2).await; db.items().mark_starred(starred_item.id, true).await.unwrap(); let fg = FeedGenerator::new(db).with_order(OrderBy::StarredFirst); let result = fg.get_items(0).await.unwrap(); assert!(result.items[0].is_starred, "first item should be starred"); assert!(!result.items[1].is_starred, "second item should not be starred"); } // ── Item-level tag filtering within get_items ──────────────── #[tokio::test] async fn get_items_tag_filter_keeps_matching() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_tagged_item(&db, &feed, "rss:rust", 1, vec!["rust".into()]).await; seed_tagged_item(&db, &feed, "rss:python", 2, vec!["python".into()]).await; seed_tagged_item(&db, &feed, "rss:both", 3, vec!["rust".into(), "go".into()]).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_tag("rust")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); let ids: Vec<&str> = result.items.iter().map(|i| i.id.item_id.as_str()).collect(); assert!(ids.contains(&"rss:rust")); assert!(ids.contains(&"rss:both")); } #[tokio::test] async fn get_items_tag_filter_no_match_returns_empty() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_tagged_item(&db, &feed, "rss:1", 1, vec!["python".into()]).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_tag("rust")); let result = fg.get_items(0).await.unwrap(); assert!(result.items.is_empty()); } #[tokio::test] async fn get_items_tag_filter_or_logic() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_tagged_item(&db, &feed, "rss:r", 1, vec!["rust".into()]).await; seed_tagged_item(&db, &feed, "rss:g", 2, vec!["go".into()]).await; seed_tagged_item(&db, &feed, "rss:p", 3, vec!["python".into()]).await; // Filter with two tags -- OR logic: items matching either tag pass let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_tag("rust").with_tag("go")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); } #[tokio::test] async fn get_items_tag_filter_items_with_no_tags_excluded() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:notagged", 1).await; // No tags seed_tagged_item(&db, &feed, "rss:tagged", 2, vec!["rust".into()]).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_tag("rust")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.item_id, "rss:tagged"); } // ── has_more correctness after in-memory tag filtering ────── #[tokio::test] async fn has_more_true_when_inmemory_filter_reduces_below_page_size_but_sql_had_more() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; // Create page_size+1 items (5 items, page_size=3), but only 1 has // the matching tag. SQL returns 4 items (page_size+1), in-memory // filtering reduces to 1 item. Since SQL had more rows and in-memory // filtering is active, has_more should be true (there might be // matching items on subsequent SQL pages). seed_tagged_item(&db, &feed, "rss:yes", 0, vec!["rust".into()]).await; for i in 1..=4 { seed_tagged_item(&db, &feed, &format!("rss:no_{i}"), i as i64, vec!["python".into()]).await; } let fg = FeedGenerator::new(db) .with_page_size(3) .with_filter(FeedFilter::new().with_tag("rust")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); // SQL returned 4 items (> page_size=3), so sql_had_more=true. // After in-memory tag filtering, only 1 item remains (< page_size). // Conservatively report has_more=true since more matching items // may exist on later SQL pages. assert!(result.has_more); } #[tokio::test] async fn has_more_false_when_no_inmemory_filter_and_few_items() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; // Only 2 items, page_size=3, no in-memory filters. seed_item(&db, &feed, "rss:1", 0).await; seed_item(&db, &feed, "rss:2", 1).await; let fg = FeedGenerator::new(db).with_page_size(3); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); assert!(!result.has_more); } #[tokio::test] async fn has_more_false_when_all_items_match_filter_and_fit_page() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; // Only 2 items, both match tag, page_size=3. SQL returns 2 (< page_size+1), // so sql_had_more=false. has_more should be false. seed_tagged_item(&db, &feed, "rss:1", 0, vec!["rust".into()]).await; seed_tagged_item(&db, &feed, "rss:2", 1, vec!["rust".into()]).await; let fg = FeedGenerator::new(db) .with_page_size(3) .with_filter(FeedFilter::new().with_tag("rust")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); assert!(!result.has_more); } // ── Source filter within get_items ─────────────────────────── #[tokio::test] async fn get_items_source_filter() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "RSS").await; let feed_b = seed_feed(&db, "hn", "HN").await; seed_item(&db, &feed_a, "rss:1", 1).await; seed_item(&db, &feed_a, "rss:2", 2).await; seed_item(&db, &feed_b, "hn:1", 3).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().source("rss")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); assert!(result.items.iter().all(|i| i.id.source == "rss")); } // ── Search filter within get_items ────────────────────────── #[tokio::test] async fn get_items_search_filter_matches_title() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 1).await; // Title: "Title rss:1" seed_item(&db, &feed, "rss:2", 2).await; // Title: "Title rss:2" let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().search("Title rss:1")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.item_id, "rss:1"); } #[tokio::test] async fn get_items_search_filter_no_match() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 1).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().search("zzz_nonexistent_zzz")); let result = fg.get_items(0).await.unwrap(); assert!(result.items.is_empty()); } // ── Combined filters within get_items ─────────────────────── #[tokio::test] async fn get_items_search_with_source_filter() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "RSS").await; let feed_b = seed_feed(&db, "hn", "HN").await; seed_item(&db, &feed_a, "rss:match", 1).await; seed_item(&db, &feed_b, "hn:match", 2).await; // Search that matches both, but restricted to rss source let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().search("Item").source("rss")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.source, "rss"); } #[tokio::test] async fn get_items_search_with_unread_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; let item1 = seed_item(&db, &feed, "rss:1", 1).await; seed_item(&db, &feed, "rss:2", 2).await; db.items().mark_read(item1.id, true).await.unwrap(); // Search matches both items, but only unread should appear let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().search("Item").unread_only()); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert!(!result.items[0].is_read); } #[tokio::test] async fn get_items_search_with_starred_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 1).await; let item2 = seed_item(&db, &feed, "rss:2", 2).await; db.items().mark_starred(item2.id, true).await.unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().search("Item").starred_only()); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert!(result.items[0].is_starred); } // ── get_all_items additional coverage ──────────────────────── #[tokio::test] async fn get_all_items_empty_db() { let db = test_db().await; let fg = FeedGenerator::new(db); let result = fg.get_all_items().await.unwrap(); assert!(result.items.is_empty()); assert!(!result.has_more); } #[tokio::test] async fn get_all_items_unread_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; let item1 = seed_item(&db, &feed, "rss:1", 1).await; seed_item(&db, &feed, "rss:2", 2).await; seed_item(&db, &feed, "rss:3", 3).await; db.items().mark_read(item1.id, true).await.unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().unread_only()); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items.len(), 2); assert!(result.items.iter().all(|i| !i.is_read)); } #[tokio::test] async fn get_all_items_starred_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 1).await; let item2 = seed_item(&db, &feed, "rss:2", 2).await; db.items().mark_starred(item2.id, true).await.unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().starred_only()); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items.len(), 1); assert!(result.items[0].is_starred); } #[tokio::test] async fn get_all_items_tag_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_tagged_item(&db, &feed, "rss:tagged", 1, vec!["rust".into()]).await; seed_item(&db, &feed, "rss:plain", 2).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().with_tag("rust")); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.item_id, "rss:tagged"); } #[tokio::test] async fn get_all_items_search_filter() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_item(&db, &feed, "rss:1", 1).await; seed_item(&db, &feed, "rss:2", 2).await; let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().search("Item rss:1")); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items.len(), 1); } #[tokio::test] async fn get_all_items_combined_source_and_unread() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "RSS").await; let feed_b = seed_feed(&db, "hn", "HN").await; let item_a1 = seed_item(&db, &feed_a, "rss:1", 1).await; seed_item(&db, &feed_a, "rss:2", 2).await; seed_item(&db, &feed_b, "hn:1", 3).await; db.items().mark_read(item_a1.id, true).await.unwrap(); let fg = FeedGenerator::new(db) .with_filter(FeedFilter::new().source("rss").unread_only()); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.source, "rss"); assert!(!result.items[0].is_read); } #[tokio::test] async fn get_all_items_score_ordering() { let db = test_db().await; let feed = seed_feed(&db, "rss", "Feed").await; seed_scored_item(&db, &feed, "rss:low", 1, Some(5)).await; seed_scored_item(&db, &feed, "rss:high", 2, Some(500)).await; seed_scored_item(&db, &feed, "rss:mid", 3, Some(50)).await; let fg = FeedGenerator::new(db).with_order(OrderBy::Score); let result = fg.get_all_items().await.unwrap(); assert_eq!(result.items[0].id.item_id, "rss:high"); assert_eq!(result.items[1].id.item_id, "rss:mid"); assert_eq!(result.items[2].id.item_id, "rss:low"); } // ── get_sources edge cases ────────────────────────────────── #[tokio::test] async fn get_sources_feed_with_no_items() { let db = test_db().await; seed_feed(&db, "rss", "Empty Feed").await; let fg = FeedGenerator::new(db); let sources = fg.get_sources().await.unwrap(); assert_eq!(sources.len(), 1); assert_eq!(sources[0].total_count, 0); assert_eq!(sources[0].unread_count, 0); } #[tokio::test] async fn get_sources_multiple_feeds_correct_counts() { let db = test_db().await; let feed_a = seed_feed(&db, "rss", "RSS").await; let feed_b = seed_feed(&db, "hn", "HN").await; seed_item(&db, &feed_a, "rss:1", 1).await; seed_item(&db, &feed_a, "rss:2", 2).await; seed_item(&db, &feed_a, "rss:3", 3).await; let hn_item = seed_item(&db, &feed_b, "hn:1", 4).await; db.items().mark_read(hn_item.id, true).await.unwrap(); let fg = FeedGenerator::new(db); let sources = fg.get_sources().await.unwrap(); let rss = sources.iter().find(|s| s.id == "rss").unwrap(); assert_eq!(rss.total_count, 3); assert_eq!(rss.unread_count, 3); let hn = sources.iter().find(|s| s.id == "hn").unwrap(); assert_eq!(hn.total_count, 1); assert_eq!(hn.unread_count, 0); } }