Tests for feeds that move, WordPress.com, Patreon and ipx add/rm (#401)
follow_move, which rewrites a feed's address in the catalogue after a permanent redirect, ran in no test; the fetch only said where a feed went. It now: the feed keeps its id, so its subscribers and their read state, takes the new address in memory and in the database, and is left as it was when another feed already has that address, or when it is an OPML's, whose list would only put the old one back. WordPress.com's browser check is answered from its API's posts, and a Patreon creator's shows are asked of Patreon by the campaign its feed names, each show the creator feed with its own show and credentials; an unknown one is an error. Both against a stand-in: the two APIs' addresses go through api_base, which a test points at a server of its own; production's are unchanged. ipx add names a feed from its title, keeps its folder and keywords, refuses it a second time, and asked again to list it or with a category changes only those; ipx rm takes it out of the catalogue and the database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key};
|
||||||
pub use patreon::{is_patreon_creator, patreon_shows, same_feed};
|
pub use patreon::{is_patreon_creator, patreon_shows, same_feed};
|
||||||
pub use privacy::{redact, redact_text, url_looks_private};
|
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/<host>/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<String> = 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#"<html><script src="/__challenge/check.js"></script></html>"#.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":"<p>Hi</p>"}}]"#;
|
||||||
|
("200 OK", post.into())
|
||||||
|
} else if path.starts_with("/creator") {
|
||||||
|
let feed = r#"<?xml version="1.0"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
|
||||||
|
<atom:link href="https://www.patreon.com/rss/12345?auth=x" rel="self"/><title>Creator</title></channel></rss>"#;
|
||||||
|
("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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
||||||
|
|
||||||
let api = format!(
|
let api = format!(
|
||||||
"https://www.patreon.com/api/campaigns/{campaign}\
|
"{}/api/campaigns/{campaign}?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title",
|
||||||
?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")?;
|
let resp = client.get(api).send().await.context("asking Patreon for the shows")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
@@ -106,6 +106,21 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::discover;
|
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]
|
#[test]
|
||||||
fn a_patreon_creator_is_a_list_of_its_shows() {
|
fn a_patreon_creator_is_a_list_of_its_shows() {
|
||||||
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// set to carry more gets ten here.
|
||||||
pub(crate) async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
|
pub(crate) async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
|
||||||
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
|
let resp = client
|
||||||
.get(&api)
|
.get(&api)
|
||||||
.timeout(fetch::FEED_TIMEOUT)
|
.timeout(fetch::FEED_TIMEOUT)
|
||||||
@@ -61,6 +61,19 @@ pub(crate) fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn reads_wordpress_posts_as_a_feed() {
|
fn reads_wordpress_posts_as_a_feed() {
|
||||||
// As WordPress.com's API sends them, trimmed (#317).
|
// As WordPress.com's API sends them, trimmed (#317).
|
||||||
|
|||||||
@@ -516,6 +516,58 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_util::{feed, test_ctx};
|
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]
|
#[tokio::test]
|
||||||
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
|
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()));
|
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
|
||||||
|
|||||||
Reference in New Issue
Block a user