//! FeedGenerator mark_read/starred, count method, and ordering //! integration tests. mod common; use bb_db::{BusserId, CreateFeedItem}; use bb_feed::{FeedFilter, FeedGenerator}; use chrono::{Duration, Utc}; // ── FeedGenerator mark_read / mark_starred + Unread Count ──────────── #[tokio::test] async fn generator_mark_read_updates_unread_count() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; common::insert_item(&db, &feed, "rss:mr1", "Article 1", 1).await; common::insert_item(&db, &feed, "rss:mr2", "Article 2", 2).await; common::insert_item(&db, &feed, "rss:mr3", "Article 3", 3).await; let fg = FeedGenerator::new(db.clone()); // All 3 unread initially assert_eq!(fg.unread_count().await.unwrap(), 3); // Mark one read via generator (uses external_id lookup) fg.mark_read("rss:mr1", true).await.unwrap(); assert_eq!(fg.unread_count().await.unwrap(), 2); // Mark another read fg.mark_read("rss:mr2", true).await.unwrap(); assert_eq!(fg.unread_count().await.unwrap(), 1); // Mark one back to unread fg.mark_read("rss:mr1", false).await.unwrap(); assert_eq!(fg.unread_count().await.unwrap(), 2); } #[tokio::test] async fn generator_mark_starred_does_not_affect_unread_count() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; common::insert_item(&db, &feed, "rss:ms1", "Article 1", 1).await; common::insert_item(&db, &feed, "rss:ms2", "Article 2", 2).await; let fg = FeedGenerator::new(db.clone()); assert_eq!(fg.unread_count().await.unwrap(), 2); // Starring should not change unread count fg.mark_starred("rss:ms1", true).await.unwrap(); assert_eq!(fg.unread_count().await.unwrap(), 2); fg.mark_starred("rss:ms1", false).await.unwrap(); assert_eq!(fg.unread_count().await.unwrap(), 2); } #[tokio::test] async fn generator_mark_read_nonexistent_preserves_count() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; common::insert_item(&db, &feed, "rss:1", "Article", 1).await; let fg = FeedGenerator::new(db.clone()); // Should not panic or error fg.mark_read("nonexistent:999", true).await.unwrap(); // Original item should remain unread assert_eq!(fg.unread_count().await.unwrap(), 1); } // ── Ordering ───────────────────────────────────────────────────────── #[tokio::test] async fn list_items_order_by_score() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; // Insert items with scores via upsert (need to use CreateFeedItem directly) for (ext_id, title, score, hours_ago) in [ ("rss:lo", "Low Score", Some(10i64), 1i64), ("rss:hi", "High Score", Some(100), 2), ("rss:no", "No Score", None, 3), ] { db.items() .upsert(CreateFeedItem { external_id: ext_id.to_string(), feed_id: feed.id, busser_id: BusserId::new("rss"), bite_author: "author".to_string(), bite_text: format!("Item {ext_id}"), bite_secondary: None, bite_indicator: None, title: Some(title.to_string()), body: None, url: None, media: vec![], published_at: Utc::now() - Duration::hours(hours_ago), source_name: "test".to_string(), score, tags: vec![], actions: vec![], }) .await .unwrap(); } let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::Score); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 3); assert_eq!(result.items[0].meta.score, Some(100)); assert_eq!(result.items[1].meta.score, Some(10)); assert_eq!(result.items[2].meta.score, None); } #[tokio::test] async fn list_items_order_starred_first() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; let item1 = common::insert_item(&db, &feed, "rss:1", "Normal", 1).await; let item2 = common::insert_item(&db, &feed, "rss:2", "Starred", 2).await; db.items().mark_starred(item2.id, true).await.unwrap(); let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::StarredFirst); let result = fg.get_items(0).await.unwrap(); assert!(result.items[0].is_starred, "starred item should sort first"); assert!(!result.items[1].is_starred); // item1 should not have id leakage assert_eq!(result.items[1].id.item_id, item1.external_id); } #[tokio::test] async fn list_items_order_unread_first() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; let item1 = common::insert_item(&db, &feed, "rss:1", "Read Item", 1).await; common::insert_item(&db, &feed, "rss:2", "Unread Item", 2).await; db.items().mark_read(item1.id, true).await.unwrap(); let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::UnreadFirst); let result = fg.get_items(0).await.unwrap(); // UnreadFirst groups items by read status — verify they're grouped let first_read = result.items[0].is_read; let last_read = result.items[1].is_read; assert_ne!(first_read, last_read, "items should be grouped by read status"); } // ── FeedGenerator Count Method ─────────────────────────────────────── #[tokio::test] async fn generator_count_all() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; common::insert_item(&db, &feed, "rss:1", "A", 1).await; common::insert_item(&db, &feed, "rss:2", "B", 2).await; common::insert_item(&db, &feed, "rss:3", "C", 3).await; let fg = FeedGenerator::new(db); assert_eq!(fg.count().await.unwrap(), 3); } #[tokio::test] async fn generator_count_with_source_filter() { let db = common::test_db().await; let feed_rss = common::create_rss_feed(&db, "RSS", "https://example.com/rss").await; let feed_hn = common::create_other_feed(&db, "hn", "HN").await; common::insert_item(&db, &feed_rss, "rss:1", "A", 1).await; common::insert_item(&db, &feed_hn, "hn:1", "B", 1).await; common::insert_item(&db, &feed_hn, "hn:2", "C", 2).await; let fg = FeedGenerator::new(db.clone()).with_filter(FeedFilter::new().source("hn")); assert_eq!(fg.count().await.unwrap(), 2); // Source that doesn't exist let gen2 = FeedGenerator::new(db).with_filter(FeedFilter::new().source("nonexistent")); assert_eq!(gen2.count().await.unwrap(), 0); } #[tokio::test] async fn generator_count_with_unread_filter() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; let item1 = common::insert_item(&db, &feed, "rss:1", "A", 1).await; common::insert_item(&db, &feed, "rss:2", "B", 2).await; common::insert_item(&db, &feed, "rss:3", "C", 3).await; db.items().mark_read(item1.id, true).await.unwrap(); let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only()); assert_eq!(fg.count().await.unwrap(), 2); }