//! Item CRUD, upsert deduplication, detail conversion, combined filters, //! empty state, and mark-read/star persisted state integration tests. mod common; use bb_db::{BusserId, CreateFeedItem, ItemId}; use bb_feed::{FeedFilter, FeedGenerator}; use chrono::Utc; // ── Item CRUD Round-trip ───────────────────────────────────────────── #[tokio::test] async fn item_get_by_uuid() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; let item = common::insert_item(&db, &feed, "rss:1", "Test Article", 1).await; let fetched = db.items().get(item.id).await.unwrap().unwrap(); assert_eq!(fetched.id, item.id); assert_eq!(fetched.title, Some("Test Article".to_string())); assert_eq!(fetched.url, Some("https://example.com/rss:1".to_string())); assert!(!fetched.is_read); assert!(!fetched.is_starred); } #[tokio::test] async fn item_get_invalid_uuid_returns_none() { let db = common::test_db().await; let result = db.items().get(ItemId::new()).await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn item_mark_read_unread_roundtrip() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; let item = common::insert_item(&db, &feed, "rss:read1", "Article", 1).await; // Initially unread assert!(!item.is_read); // Mark read db.items().mark_read(item.id, true).await.unwrap(); let fetched = db.items().get(item.id).await.unwrap().unwrap(); assert!(fetched.is_read); // Mark unread again db.items().mark_read(item.id, false).await.unwrap(); let fetched = db.items().get(item.id).await.unwrap().unwrap(); assert!(!fetched.is_read); } #[tokio::test] async fn item_star_unstar_roundtrip() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; let item = common::insert_item(&db, &feed, "rss:star1", "Article", 1).await; // Initially unstarred assert!(!item.is_starred); // Star db.items().mark_starred(item.id, true).await.unwrap(); let fetched = db.items().get(item.id).await.unwrap().unwrap(); assert!(fetched.is_starred); // Unstar db.items().mark_starred(item.id, false).await.unwrap(); let fetched = db.items().get(item.id).await.unwrap().unwrap(); assert!(!fetched.is_starred); } #[tokio::test] async fn item_read_and_star_are_independent() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; let item = common::insert_item(&db, &feed, "rss:indep", "Article", 1).await; // Star it but leave unread db.items().mark_starred(item.id, true).await.unwrap(); let fetched = db.items().get(item.id).await.unwrap().unwrap(); assert!(fetched.is_starred); assert!(!fetched.is_read); // Now mark read -- starred should remain db.items().mark_read(item.id, true).await.unwrap(); let fetched = db.items().get(item.id).await.unwrap().unwrap(); assert!(fetched.is_starred); assert!(fetched.is_read); } #[tokio::test] async fn item_unread_count() { 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:c1", "A", 1).await; common::insert_item(&db, &feed, "rss:c2", "B", 2).await; common::insert_item(&db, &feed, "rss:c3", "C", 3).await; // All 3 unread initially assert_eq!(db.items().count_unread().await.unwrap(), 3); // Mark one as read db.items().mark_read(item1.id, true).await.unwrap(); assert_eq!(db.items().count_unread().await.unwrap(), 2); } // ── Item List via FeedGenerator ────────────────────────────────────── #[tokio::test] async fn list_items_pagination() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; for i in 0..7 { common::insert_item(&db, &feed, &format!("rss:p{i}"), &format!("Item {i}"), i).await; } let fg = FeedGenerator::new(db).with_page_size(3); let page0 = fg.get_items(0).await.unwrap(); assert_eq!(page0.items.len(), 3); assert!(page0.has_more); let page1 = fg.get_items(1).await.unwrap(); assert_eq!(page1.items.len(), 3); assert!(page1.has_more); let page2 = fg.get_items(2).await.unwrap(); assert_eq!(page2.items.len(), 1); assert!(!page2.has_more); } #[tokio::test] async fn list_items_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", "RSS Article", 1).await; common::insert_item(&db, &feed_hn, "hn:1", "HN Article", 1).await; common::insert_item(&db, &feed_hn, "hn:2", "HN Article 2", 2).await; let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); for item in &result.items { assert_eq!(item.id.source, "hn"); } } #[tokio::test] async fn list_items_unread_only() { 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:u1", "A", 1).await; common::insert_item(&db, &feed, "rss:u2", "B", 2).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); assert!(!result.items[0].is_read); } #[tokio::test] async fn list_items_starred_only() { 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:s1", "A", 1).await; let item2 = common::insert_item(&db, &feed, "rss:s2", "B", 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_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert!(result.items[0].is_starred); } // ── Item Detail Conversion ─────────────────────────────────────────── #[tokio::test] async fn item_detail_fields_round_trip() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; db.items() .upsert(CreateFeedItem { external_id: "rss:detail".to_string(), feed_id: feed.id, busser_id: BusserId::new("rss"), bite_author: "John Doe".to_string(), bite_text: "A fascinating article".to_string(), bite_secondary: Some("42 points".to_string()), bite_indicator: Some("hot".to_string()), title: Some("Deep Dive into Rust".to_string()), body: Some("

Full article body here

".to_string()), url: Some("https://example.com/article".to_string()), media: vec!["https://example.com/image.jpg".to_string()], published_at: Utc::now(), source_name: "Test Source".to_string(), score: Some(42), tags: vec!["rust".to_string(), "programming".to_string()], actions: vec![], }) .await .unwrap(); let item = db .items() .get_by_external_id("rss:detail") .await .unwrap() .unwrap(); // Verify all fields are stored and retrieved correctly assert_eq!(item.bite_author, "John Doe"); assert_eq!(item.bite_text, "A fascinating article"); assert_eq!(item.bite_secondary, Some("42 points".to_string())); assert_eq!(item.bite_indicator, Some("hot".to_string())); assert_eq!(item.title, Some("Deep Dive into Rust".to_string())); assert_eq!( item.body, Some("

Full article body here

".to_string()) ); assert_eq!(item.url, Some("https://example.com/article".to_string())); assert_eq!(item.media_vec(), vec!["https://example.com/image.jpg"]); assert_eq!(item.score, Some(42)); let mut tags = item.tags_vec(); tags.sort(); assert_eq!(tags, vec!["programming", "rust"]); // Verify to_feed_item conversion preserves fields let feed_item = item.to_feed_item(); assert_eq!(feed_item.bite.author, "John Doe"); assert_eq!(feed_item.bite.text, "A fascinating article"); assert_eq!( feed_item.content.title, Some("Deep Dive into Rust".to_string()) ); assert_eq!( feed_item.content.body, Some("

Full article body here

".to_string()) ); assert_eq!(feed_item.meta.score, Some(42)); } // ── Item Upsert Deduplication ──────────────────────────────────────── #[tokio::test] async fn upsert_same_external_id_updates_not_duplicates() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; // Insert an item common::insert_item(&db, &feed, "rss:dedup", "Original Title", 1).await; // Upsert with same external_id but different title db.items() .upsert(CreateFeedItem { external_id: "rss:dedup".to_string(), feed_id: feed.id, busser_id: BusserId::new("rss"), bite_author: "author".to_string(), bite_text: "Updated text".to_string(), bite_secondary: None, bite_indicator: None, title: Some("Updated Title".to_string()), body: None, url: None, media: vec![], published_at: Utc::now(), source_name: "test".to_string(), score: None, tags: vec![], actions: vec![], }) .await .unwrap(); // Should still have only 1 item, not 2 assert_eq!(db.items().count_all().await.unwrap(), 1); let item = db .items() .get_by_external_id("rss:dedup") .await .unwrap() .unwrap(); assert_eq!(item.title, Some("Updated Title".to_string())); } // ── Combined Filters ───────────────────────────────────────────────── #[tokio::test] async fn list_items_combined_source_unread_starred() { 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; let rss1 = common::insert_item(&db, &feed_rss, "rss:1", "RSS Item 1", 1).await; let rss2 = common::insert_item(&db, &feed_rss, "rss:2", "RSS Item 2", 2).await; common::insert_item(&db, &feed_hn, "hn:1", "HN Item", 1).await; // rss:1 is read, rss:2 is unread+starred db.items().mark_read(rss1.id, true).await.unwrap(); db.items().mark_starred(rss2.id, true).await.unwrap(); // Filter: source=rss — gets both RSS items (source branch doesn't also // filter by unread; only list_search combines source+unread) let fg = FeedGenerator::new(db.clone()) .with_filter(FeedFilter::new().source("rss")); let result = fg.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 2); for item in &result.items { assert_eq!(item.id.source, "rss"); } // Filter: starred only — should get rss:2 only let fg2 = FeedGenerator::new(db.clone()) .with_filter(FeedFilter::new().starred_only()); let result2 = fg2.get_items(0).await.unwrap(); assert_eq!(result2.items.len(), 1); assert!(result2.items[0].is_starred); // Search + source + unread combines all filters in SQL let fg3 = FeedGenerator::new(db) .with_filter(FeedFilter::new().search("RSS").source("rss").unread_only()); let result3 = fg3.get_items(0).await.unwrap(); assert_eq!(result3.items.len(), 1); assert!(!result3.items[0].is_read); } // ── Empty State Handling ───────────────────────────────────────────── #[tokio::test] async fn list_items_empty_database() { let db = common::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 list_sources_empty_database() { let db = common::test_db().await; let fg = FeedGenerator::new(db); let sources = fg.get_sources().await.unwrap(); assert!(sources.is_empty()); } #[tokio::test] async fn unread_count_empty_database() { let db = common::test_db().await; assert_eq!(db.items().count_unread().await.unwrap(), 0); let fg = FeedGenerator::new(db); assert_eq!(fg.unread_count().await.unwrap(), 0); } #[tokio::test] async fn count_empty_database() { let db = common::test_db().await; let fg = FeedGenerator::new(db); assert_eq!(fg.count().await.unwrap(), 0); } // ── Mark Read/Star: Verify Persisted State in Filtered Queries ─────── #[tokio::test] async fn mark_read_excludes_from_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:ex1", "Article 1", 1).await; common::insert_item(&db, &feed, "rss:ex2", "Article 2", 2).await; common::insert_item(&db, &feed, "rss:ex3", "Article 3", 3).await; let fg = FeedGenerator::new(db.clone()) .with_filter(FeedFilter::new().unread_only()); // All 3 visible assert_eq!(fg.get_items(0).await.unwrap().items.len(), 3); // Mark one read db.items().mark_read(item1.id, true).await.unwrap(); // Only 2 visible now assert_eq!(fg.get_items(0).await.unwrap().items.len(), 2); // None of the returned items should be the read one let ids: Vec = fg .get_items(0) .await .unwrap() .items .iter() .map(|i| i.id.item_id.clone()) .collect(); assert!(!ids.contains(&"rss:ex1".to_string())); } #[tokio::test] async fn star_item_appears_in_starred_filter() { 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:st1", "Article 1", 1).await; let item2 = common::insert_item(&db, &feed, "rss:st2", "Article 2", 2).await; common::insert_item(&db, &feed, "rss:st3", "Article 3", 3).await; let fg_starred = FeedGenerator::new(db.clone()) .with_filter(FeedFilter::new().starred_only()); // Nothing starred yet assert!(fg_starred.get_items(0).await.unwrap().items.is_empty()); // Star one item db.items().mark_starred(item2.id, true).await.unwrap(); let result = fg_starred.get_items(0).await.unwrap(); assert_eq!(result.items.len(), 1); assert_eq!(result.items[0].id.item_id, "rss:st2"); assert!(result.items[0].is_starred); // Unstar it db.items().mark_starred(item2.id, false).await.unwrap(); assert!(fg_starred.get_items(0).await.unwrap().items.is_empty()); } // ── Upsert Preserves User State ────────────────────────────────────── #[tokio::test] async fn upsert_preserves_read_and_starred_state() { let db = common::test_db().await; let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; // Insert initial item let item = common::insert_item(&db, &feed, "rss:preserve", "Original", 1).await; // Mark it read and starred db.items().mark_read(item.id, true).await.unwrap(); db.items().mark_starred(item.id, true).await.unwrap(); // Re-upsert with updated content (simulating a feed re-fetch) db.items() .upsert(CreateFeedItem { external_id: "rss:preserve".to_string(), feed_id: feed.id, busser_id: BusserId::new("rss"), bite_author: "new_author".to_string(), bite_text: "Updated text".to_string(), bite_secondary: None, bite_indicator: None, title: Some("Updated Title".to_string()), body: Some("Updated body".to_string()), url: None, media: vec![], published_at: Utc::now(), source_name: "test".to_string(), score: Some(99), tags: vec![], actions: vec![], }) .await .unwrap(); // Verify user state preserved, content updated let updated = db .items() .get_by_external_id("rss:preserve") .await .unwrap() .unwrap(); assert!(updated.is_read, "read state should be preserved across upsert"); assert!(updated.is_starred, "starred state should be preserved across upsert"); assert_eq!(updated.title, Some("Updated Title".to_string())); assert_eq!(updated.bite_author, "new_author"); assert_eq!(updated.score, Some(99)); }