#![cfg(feature = "rss")]
#![expect(
clippy::expect_used,
clippy::panic,
reason = "integration test: panicking on unexpected input is the assertion"
)]
use std::path::PathBuf;
use rama_core::futures::StreamExt as _;
use rama_http::protocols::rss::{Feed, FeedItem, FeedStream};
use rama_net::uri::Uri;
fn fixture(name: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("rss-corpus")
.join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}
async fn parse(bytes: Vec<u8>) -> Feed {
let cursor = std::io::Cursor::new(bytes);
let reader = tokio::io::BufReader::new(cursor);
FeedStream::new(reader)
.await
.expect("FeedStream::new")
.collect()
.await
.unwrap_or_else(|e| panic!("collect: {}", e.error))
}
#[tokio::test]
async fn feed_accessors_agree_on_shared_properties() {
let rss = parse(fixture("blog-minimal.rss.xml")).await;
let atom = parse(fixture("blog-atom.atom.xml")).await;
assert_eq!(rss.title(), "Example Blog");
assert_eq!(atom.title(), "Example Blog");
assert_eq!(rss.link(), Some(&Uri::from_static("https://example.com/")));
assert_eq!(atom.link(), Some(&Uri::from_static("https://example.com/")));
}
#[tokio::test]
async fn feed_accessors_document_per_spec_divergence() {
let rss = parse(fixture("blog-minimal.rss.xml")).await;
let atom = parse(fixture("blog-atom.atom.xml")).await;
assert_eq!(rss.id(), None);
assert!(atom.id().is_some(), "Atom id missing");
assert_eq!(rss.updated(), None);
assert!(atom.updated().is_some(), "Atom updated missing");
assert!(rss.published().is_some(), "RSS pubDate missing");
assert_eq!(atom.published(), None);
assert_eq!(rss.language(), Some("en-us"));
assert_eq!(atom.language(), None);
assert_eq!(rss.self_link(), None);
assert_eq!(
atom.self_link(),
Some(&Uri::from_static("https://example.com/feed.atom")),
"Atom rel=self should be picked up"
);
assert_eq!(rss.icon_url(), None);
assert_eq!(
atom.icon_url(),
Some(&Uri::from_static("https://example.com/icon.png"))
);
assert_eq!(rss.image_url(), None);
assert_eq!(
atom.image_url(),
Some(&Uri::from_static("https://example.com/logo.png"))
);
}
#[tokio::test]
async fn feed_item_accessors_via_feedstream_agree_across_formats() {
for fname in ["blog-minimal.rss.xml", "blog-atom.atom.xml"] {
let cursor = std::io::Cursor::new(fixture(fname));
let reader = tokio::io::BufReader::new(cursor);
let mut stream = FeedStream::new(reader).await.expect("FeedStream::new");
assert_eq!(stream.title(), "Example Blog", "{fname}");
let mut items = Vec::new();
while let Some(item) = stream.next().await {
items.push(item.expect("item ok"));
}
assert_eq!(items.len(), 1, "{fname}: expected exactly one item");
let item = &items[0];
assert_eq!(item.title(), Some("Hello, world"), "{fname}");
assert_eq!(
item.link(),
Some(&Uri::from_static("https://example.com/posts/hello")),
"{fname}",
);
assert!(item.id().is_some(), "{fname}: both fixtures carry an id");
assert!(
item.published().is_some(),
"{fname}: both fixtures carry a published timestamp",
);
}
}
#[tokio::test]
async fn feed_item_rss_content_falls_back_to_description() {
let Feed::Rss2(feed) = parse(fixture("blog-minimal.rss.xml")).await else {
panic!("expected RSS");
};
let fi: FeedItem = feed.items.into_iter().next().expect("one item").into();
assert_eq!(fi.summary(), Some("A first post."));
assert_eq!(
fi.content(),
Some("A first post."),
"content() should fall back to description when no content:encoded",
);
}
#[tokio::test]
async fn feed_item_atom_keeps_summary_and_content_distinct() {
let Feed::Atom(feed) = parse(fixture("blog-atom.atom.xml")).await else {
panic!("expected Atom");
};
let fi: FeedItem = feed.entries.into_iter().next().expect("one entry").into();
assert_eq!(fi.summary(), Some("A first post."));
assert_eq!(
fi.content(),
Some("<p>Hello <strong>world</strong>.</p>"),
"Atom content should be returned distinct from summary",
);
}
async fn parse_str(xml: &str) -> Feed {
parse(xml.as_bytes().to_vec()).await
}
#[tokio::test]
async fn atom_preserves_whitespace_around_entities() {
let Feed::Atom(feed) = parse_str(
r#"<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:test</id><title>t</title><updated>2025-01-01T00:00:00Z</updated>
<entry>
<id>urn:e1</id>
<title type="text">Tom & Jerry</title>
<updated>2025-01-01T00:00:00Z</updated>
<content type="html"><p>a <b>c</b> d</p></content>
</entry>
</feed>"#,
)
.await
else {
panic!("expected Atom");
};
let fi: FeedItem = feed.entries.into_iter().next().expect("entry").into();
assert_eq!(
fi.title(),
Some("Tom & Jerry"),
"spaces around `&` must survive entity splitting",
);
assert_eq!(
fi.content(),
Some("<p>a <b>c</b> d</p>"),
"html body must keep whitespace interior to the value (incl. around tags)",
);
}
#[tokio::test]
async fn atom_preserves_numeric_char_ref_midword() {
let Feed::Atom(feed) = parse_str(
r#"<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:test</id><title>t</title><updated>2025-01-01T00:00:00Z</updated>
<entry>
<id>urn:e1</id>
<title type="text">café au lait</title>
<updated>2025-01-01T00:00:00Z</updated>
</entry>
</feed>"#,
)
.await
else {
panic!("expected Atom");
};
let fi: FeedItem = feed.entries.into_iter().next().expect("entry").into();
assert_eq!(
fi.title(),
Some("café au lait"),
"a numeric char ref must not split the surrounding word",
);
}
#[tokio::test]
async fn rss2_trims_outer_whitespace_but_keeps_interior_entity_spacing() {
let Feed::Rss2(feed) = parse_str(
r#"<rss version="2.0"><channel>
<title>t</title><link>https://e.com/</link><description>d</description>
<item>
<title>Tom & Jerry</title>
<description> before & after </description>
</item>
</channel></rss>"#,
)
.await
else {
panic!("expected RSS2");
};
let fi: FeedItem = feed.items.into_iter().next().expect("item").into();
assert_eq!(fi.title(), Some("Tom & Jerry"));
assert_eq!(
fi.summary(),
Some("before & after"),
"outer whitespace trimmed; interior spacing around `&` preserved",
);
}
#[tokio::test]
async fn feed_item_enclosures_normalised_across_formats() {
let Feed::Rss2(rss) = parse(fixture("edge-multiple-enclosures.rss.xml")).await else {
panic!()
};
let item: FeedItem = rss.items.into_iter().next().expect("one item").into();
let encs: Vec<_> = item.enclosures().collect();
assert!(
!encs.is_empty(),
"fixture should declare at least one enclosure",
);
for enc in &encs {
assert!(enc.length.is_some(), "RSS enclosure length should be Some");
assert!(enc.mime.is_some(), "RSS enclosure mime should be Some");
}
}