//! 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()));
}