//! OPML export and import integration tests. mod common; use bb_db::{BusserId, CreateFeed, Database}; // ── OPML-specific helpers ──────────────────────────────────────────── /// Replicate the OPML export logic from `commands/opml.rs::export_opml`. fn export_opml_from_feeds(feeds: &[bb_db::DbFeed]) -> String { let mut outlines = String::new(); for feed in feeds { if feed.busser_id != "rss" { continue; } let config = feed.config_json(); let feed_url = config .get("feed_url") .and_then(|v| v.as_str()) .unwrap_or(""); if feed_url.is_empty() { continue; } outlines.push_str(&format!( " \n", xml_escape(&feed.name), xml_escape(feed_url), )); } format!( "\n\ \n\ \x20 \n\ \x20 Balanced Breakfast Feeds\n\ \x20 \n\ \x20 \n\ {}\ \x20 \n\ \n", outlines ) } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// Replicate the OPML import logic from `commands/opml.rs::import_opml`. async fn import_opml(db: &Database, content: &str) -> Result<(usize, usize, Vec), String> { let doc = roxmltree::Document::parse(content) .map_err(|e| format!("Invalid OPML: {}", e))?; let existing_feeds = db.feeds().list_all().await.map_err(|e| e.to_string())?; let existing_urls: std::collections::HashSet = existing_feeds .iter() .filter(|f| f.busser_id == "rss") .filter_map(|f| { f.config_json() .get("feed_url") .and_then(|v| v.as_str()) .map(|s| s.to_string()) }) .collect(); let mut imported = 0; let mut skipped = 0; let mut errors = Vec::new(); for node in doc.descendants() { if !node.is_element() || node.tag_name().name() != "outline" { continue; } let xml_url = match node.attribute("xmlUrl") { Some(url) if !url.is_empty() => url, _ => continue, }; if existing_urls.contains(xml_url) { skipped += 1; continue; } let name = node .attribute("text") .or_else(|| node.attribute("title")) .unwrap_or(xml_url) .to_string(); let config = serde_json::json!({ "feed_url": xml_url }); let create = CreateFeed { busser_id: BusserId::new("rss"), name, config, }; match db.feeds().create(create).await { Ok(_) => imported += 1, Err(e) => errors.push(format!("{}: {}", xml_url, e)), } } Ok((imported, skipped, errors)) } // ── OPML Export ────────────────────────────────────────────────────── #[tokio::test] async fn opml_export_empty_state() { let db = common::test_db().await; let feeds = db.feeds().list_all().await.unwrap(); let opml = export_opml_from_feeds(&feeds); assert!(opml.contains("")); assert!(opml.contains("Balanced Breakfast Feeds")); // No outline elements assert!(!opml.contains("", "https://example.com/rss?a=1&b=2").await; let feeds = db.feeds().list_all().await.unwrap(); let opml = export_opml_from_feeds(&feeds); assert!(opml.contains("text=\"Tom & Jerry's <Feed>\"")); assert!(opml.contains("xmlUrl=\"https://example.com/rss?a=1&b=2\"")); } // ── OPML Import ────────────────────────────────────────────────────── #[tokio::test] async fn opml_import_valid_opml() { let db = common::test_db().await; let opml = r#" Test "#; let (imported, skipped, errors) = import_opml(&db, opml).await.unwrap(); assert_eq!(imported, 2); assert_eq!(skipped, 0); assert!(errors.is_empty()); let feeds = db.feeds().list_all().await.unwrap(); assert_eq!(feeds.len(), 2); let names: Vec<&str> = feeds.iter().map(|f| f.name.as_str()).collect(); assert!(names.contains(&"Rust Blog")); assert!(names.contains(&"Lobsters")); } #[tokio::test] async fn opml_import_invalid_xml() { let db = common::test_db().await; let result = import_opml(&db, "this is not XML").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid OPML")); } #[tokio::test] async fn opml_import_skips_duplicates() { let db = common::test_db().await; // Pre-create a feed with this URL common::create_rss_feed(&db, "Existing", "https://blog.rust-lang.org/feed.xml").await; let opml = r#" "#; let (imported, skipped, errors) = import_opml(&db, opml).await.unwrap(); assert_eq!(imported, 1); assert_eq!(skipped, 1); assert!(errors.is_empty()); let feeds = db.feeds().list_all().await.unwrap(); assert_eq!(feeds.len(), 2); // original + 1 new } #[tokio::test] async fn opml_import_uses_text_or_title_or_url_as_name() { let db = common::test_db().await; let opml = r#" "#; let (imported, _skipped, _errors) = import_opml(&db, opml).await.unwrap(); assert_eq!(imported, 3); let feeds = db.feeds().list_all().await.unwrap(); let names: Vec<&str> = feeds.iter().map(|f| f.name.as_str()).collect(); assert!(names.contains(&"Text Name")); assert!(names.contains(&"Title Name")); assert!(names.contains(&"https://c.com/rss")); } #[tokio::test] async fn opml_import_ignores_outlines_without_xml_url() { let db = common::test_db().await; let opml = r#" "#; let (imported, _skipped, _errors) = import_opml(&db, opml).await.unwrap(); assert_eq!(imported, 1); } #[tokio::test] async fn opml_import_handles_nested_outlines() { let db = common::test_db().await; let opml = r#" "#; let (imported, _skipped, _errors) = import_opml(&db, opml).await.unwrap(); assert_eq!(imported, 3); } #[tokio::test] async fn opml_roundtrip_export_then_import() { let db = common::test_db().await; common::create_rss_feed(&db, "Feed A", "https://a.example.com/feed.xml").await; common::create_rss_feed(&db, "Feed B", "https://b.example.com/rss").await; // Export let feeds = db.feeds().list_all().await.unwrap(); let opml = export_opml_from_feeds(&feeds); // Import into a fresh DB let db2 = common::test_db().await; let (imported, skipped, errors) = import_opml(&db2, &opml).await.unwrap(); assert_eq!(imported, 2); assert_eq!(skipped, 0); assert!(errors.is_empty()); // Verify feed names and URLs match let reimported = db2.feeds().list_all().await.unwrap(); assert_eq!(reimported.len(), 2); let urls: Vec = reimported .iter() .filter_map(|f| { f.config_json() .get("feed_url") .and_then(|v| v.as_str()) .map(|s| s.to_string()) }) .collect(); assert!(urls.contains(&"https://a.example.com/feed.xml".to_string())); assert!(urls.contains(&"https://b.example.com/rss".to_string())); }