diff --git a/crates/feed/src/lib.rs b/crates/feed/src/lib.rs index e685b9d..c7a9fd5 100644 --- a/crates/feed/src/lib.rs +++ b/crates/feed/src/lib.rs @@ -24,3 +24,69 @@ pub use outlines::{collect_outlines, is_opml, opml_title, parse_opml, read_opml} pub use parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key}; pub use patreon::{is_patreon_creator, patreon_shows, same_feed}; pub use privacy::{redact, redact_text, url_looks_private}; + +/// Where another service's API is (WordPress.com's, Patreon's): theirs, or in a test the stand-in +/// `test_api` serves. +pub(crate) fn api_base(real: &'static str) -> String { + #[cfg(test)] + { + let _ = real; + test_api::base() + } + #[cfg(not(test))] + real.to_owned() +} + +#[cfg(test)] +pub(crate) mod test_api { + use std::io::{BufRead, BufReader, Write}; + + /// A stand-in for WordPress.com's and Patreon's APIs, and for the feeds that send ipx to them, + /// on a thread of its own so it outlives any one test's runtime. By path: + /// `/challenged` answers as WordPress.com's browser check does, `/wp/v2/sites//posts` + /// with one post, `/creator` as a Patreon creator feed of campaign 12345, and + /// `/api/campaigns/12345` with its two shows. Anything else is a 404. + pub(crate) fn base() -> String { + static AT: std::sync::OnceLock = std::sync::OnceLock::new(); + AT.get_or_init(|| { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let at = format!("http://{}", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut sock in l.incoming().flatten() { + let mut line = String::new(); + let _ = BufReader::new(&sock).read_line(&mut line); + let path = line.split_whitespace().nth(1).unwrap_or("/").to_owned(); + let (status, body) = answer(&path); + let _ = write!( + sock, + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + at + }) + .clone() + } + + fn answer(path: &str) -> (&'static str, String) { + if path.starts_with("/challenged") { + ("403 Forbidden", r#""#.into()) + } else if path.starts_with("/wp/v2/sites/127.0.0.1/posts") { + let post = r#"[{"date_gmt":"2026-10-09T13:34:00","guid":{"rendered":"https://blog.test/?p=1"}, + "link":"https://blog.test/hello/","title":{"rendered":"Hello"},"content":{"rendered":"

Hi

"}}]"#; + ("200 OK", post.into()) + } else if path.starts_with("/creator") { + let feed = r#" + Creator"#; + ("200 OK", feed.into()) + } else if path.starts_with("/api/campaigns/12345?") { + let shows = r#"{"data":{"attributes":{"name":"Glass Cannon"}, + "relationships":{"shows":{"data":[{"id":"7","type":"collection"},{"id":"8","type":"collection"}]}}}, + "included":[{"id":"7","type":"collection","attributes":{"title":" Main Show "}}]}"#; + ("200 OK", shows.into()) + } else { + ("404 Not Found", String::new()) + } + } +} diff --git a/crates/feed/src/patreon.rs b/crates/feed/src/patreon.rs index 0436b25..b4c2d1f 100644 --- a/crates/feed/src/patreon.rs +++ b/crates/feed/src/patreon.rs @@ -58,8 +58,8 @@ pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Optio let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?; let api = format!( - "https://www.patreon.com/api/campaigns/{campaign}\ - ?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title" + "{}/api/campaigns/{campaign}?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title", + crate::api_base("https://www.patreon.com") ); let resp = client.get(api).send().await.context("asking Patreon for the shows")?; if !resp.status().is_success() { @@ -106,6 +106,21 @@ mod tests { use super::*; use crate::discover; + #[tokio::test] + async fn a_creators_shows_are_asked_of_patreon_by_the_campaign_its_feed_names() { + let client = reqwest::Client::new(); + let creator = format!("{}/creator?auth=x", crate::test_api::base()); + let (name, shows) = patreon_shows(&client, &creator).await.unwrap(); + assert_eq!(name.as_deref(), Some("Glass Cannon")); + assert_eq!( + shows, + [("Main Show".to_owned(), format!("{creator}&show=7")), ("Show 8".to_owned(), format!("{creator}&show=8"))], + "each show is the creator feed with its own show, on the same credentials" + ); + let nobody = format!("{}/nothing?auth=x", crate::test_api::base()); + assert!(format!("{:#}", patreon_shows(&client, &nobody).await.unwrap_err()).contains("HTTP 404")); + } + #[test] fn a_patreon_creator_is_a_list_of_its_shows() { let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9"; diff --git a/crates/feed/src/wordpress.rs b/crates/feed/src/wordpress.rs index 8202a69..e540478 100644 --- a/crates/feed/src/wordpress.rs +++ b/crates/feed/src/wordpress.rs @@ -15,7 +15,7 @@ pub(crate) fn is_wpcom_challenge(body: &[u8]) -> bool { /// shortcut: the API's default page of ten posts, as many as WordPress puts in a feed; a feed /// set to carry more gets ten here. pub(crate) async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result { - let api = format!("https://public-api.wordpress.com/wp/v2/sites/{host}/posts"); + let api = format!("{}/wp/v2/sites/{host}/posts", crate::api_base("https://public-api.wordpress.com")); let resp = client .get(&api) .timeout(fetch::FEED_TIMEOUT) @@ -61,6 +61,19 @@ pub(crate) fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed { #[cfg(test)] mod tests { + #[tokio::test] + async fn a_browser_check_in_place_of_the_feed_is_answered_from_wordpress_coms_api() { + let client = reqwest::Client::new(); + let (got, moved) = + crate::fetch(&client, &format!("{}/challenged/feed", crate::test_api::base()), None, None, None) + .await + .unwrap(); + let crate::Fetched::Body { bytes, .. } = got else { panic!("a body") }; + let feed = super::parse(&bytes).unwrap(); + assert_eq!(feed.entries[0].guid, "https://blog.test/?p=1", "the post the site's own feed would have"); + assert_eq!((feed.entries[0].title.as_deref(), moved), (Some("Hello"), None)); + } + #[test] fn reads_wordpress_posts_as_a_feed() { // As WordPress.com's API sends them, trimmed (#317). diff --git a/crates/ipx/src/catalogue.rs b/crates/ipx/src/catalogue.rs index c63dca7..ffcf2c4 100644 --- a/crates/ipx/src/catalogue.rs +++ b/crates/ipx/src/catalogue.rs @@ -516,6 +516,58 @@ mod tests { use super::*; use crate::test_util::{feed, test_ctx}; + #[tokio::test] + async fn a_feed_that_moved_for_good_takes_its_new_address_unless_another_feed_has_it() { + let mut cfg = config::Config::default(); + for id in ["moving", "other"] { + cfg.feeds.insert(id.into(), config::Feed { url: format!("http://x/{id}.xml"), ..feed() }); + } + let ctx = test_ctx(cfg).await; + ctx.db.store_config(&ctx.cfg()).await.unwrap(); + ctx.db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1, 'dee', false); INSERT INTO subscriptions (user_id, feed_id) VALUES (1, 'moving');").await.unwrap(); + + follow_move(&ctx, "moving", "https://x/new.xml").await.unwrap(); + assert_eq!(ctx.cfg().feeds["moving"].url, "https://x/new.xml"); + let stored = ctx.db.stored_config().await.unwrap().unwrap().1; + assert_eq!(stored["moving"].url, "https://x/new.xml", "kept, not only in memory"); + // The id stays, so whoever subscribed keeps it, with their read state. + assert_eq!(ctx.db.i64s_for_test("SELECT user_id FROM subscriptions WHERE feed_id = 'moving'").await, [1]); + + // Onto an address another feed has: two would be the same feed under two ids. + follow_move(&ctx, "moving", "https://x/other.xml").await.unwrap(); + assert_eq!(ctx.cfg().feeds["moving"].url, "https://x/new.xml", "left where it was"); + follow_move(&ctx, "from-an-opml", "https://x/elsewhere.xml").await.unwrap(); + assert!(!ctx.cfg().feeds.contains_key("from-an-opml"), "its OPML would only put it back"); + } + + #[tokio::test] + async fn ipx_add_and_rm_change_the_catalogue() { + let base = crate::test_util::empty_or_feed_server().await; + let ctx = test_ctx(config::Config::default()).await; + let url = format!("{base}/feed"); + add(&ctx, &url, Some("Shows".into()), vec!["rust".into()], None, false).await.unwrap(); + let (id, f) = ctx.cfg().feeds.iter().next().map(|(id, f)| (id.clone(), f.clone())).expect("added"); + assert_eq!(id, "t", "named for its title, T"); + assert_eq!( + (f.url.as_str(), f.folder.as_deref(), f.keywords.as_slice()), + (url.as_str(), Some("Shows"), &["rust".to_owned()][..]) + ); + assert!(!f.listed); + + let again = add(&ctx, &url, None, vec![], None, false).await.unwrap_err(); + assert!(format!("{again:#}").contains("already subscribed"), "{again:#}"); + // Asking again to list it, or with a category, changes only that. + add(&ctx, &url, None, vec![], Some("News".into()), true).await.unwrap(); + let f = &ctx.cfg().feeds[&id]; + assert!(f.listed && f.category.as_deref() == Some("News") && f.folder.as_deref() == Some("Shows")); + assert_eq!(ctx.cfg().feeds.len(), 1); + + rm(&ctx, &id).await.unwrap(); + assert!(ctx.cfg().feeds.is_empty()); + let stored = ctx.db.stored_config().await.unwrap().map(|s| s.1).unwrap_or_default(); + assert!(stored.is_empty(), "gone from the database too"); + } + #[tokio::test] async fn the_first_start_moves_the_configuration_in_and_trims_the_file() { let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));