weavesignal/crates/control/tests/shows_db.rs
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

923 lines
30 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! Shows, cards and feeds through the control plane's routes against a real PostgreSQL (M8's
//! done-when): a show and a season drafted from its rule, an episode set and put on air, its
//! files tagged with its card, and the feed listing it with both enclosures and its card.
//! Ignored unless asked for (`just test-db`).
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use tower::ServiceExt;
use weavesignal_control::config::PublishingConfig;
use weavesignal_control::shows::{ShowsState, router};
use weavesignal_model::{Station, StationId, ensure_station};
const ADMIN: &str = "shows-db-test";
async fn send(app: &axum::Router, req: Request<Body>) -> (StatusCode, Vec<u8>) {
let res = app.clone().oneshot(req).await.unwrap();
let code = res.status();
(
code,
res.into_body().collect().await.unwrap().to_bytes().to_vec(),
)
}
async fn json(
app: &axum::Router,
method: &str,
uri: &str,
body: serde_json::Value,
) -> (StatusCode, serde_json::Value) {
json_as(app, ADMIN, method, uri, body).await
}
async fn json_as(
app: &axum::Router,
bearer: &str,
method: &str,
uri: &str,
body: serde_json::Value,
) -> (StatusCode, serde_json::Value) {
let (code, bytes) = send(
app,
Request::builder()
.method(method)
.uri(uri)
.header(header::AUTHORIZATION, format!("Bearer {bearer}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await;
(code, serde_json::from_slice(&bytes).unwrap_or_default())
}
fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_aired_show_appears_in_its_feed_with_both_enclosures_and_its_card() {
let url =
std::env::var("WEAVESIGNAL_TEST_DATABASE_URL").expect("WEAVESIGNAL_TEST_DATABASE_URL");
let pool = sqlx::PgPool::connect(&url).await.expect("connect");
let station = ensure_station(
&pool,
&Station::new(StationId::new_random(), "Kettle FM").unwrap(),
)
.await
.unwrap();
let dir =
PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("shows-{}", uuid::Uuid::new_v4()));
let episodes_dir = dir.join("episodes");
std::fs::create_dir_all(&episodes_dir).unwrap();
let app = router(Arc::new(ShowsState {
station,
db: Some(pool),
admin_token: Some(ADMIN.into()),
staff: Default::default(),
publishing: PublishingConfig {
public_url: "https://kettle.example".into(),
place: "Dunfermline".into(),
owner_email: "podcasts@kettle.example".into(),
copyright: "2026 Kettle FM".into(),
cards_dir: dir.join("cards"),
episodes_dir: episodes_dir.clone(),
..PublishingConfig::default()
},
zone: Arc::new(weavesignal_control::zone::StationZone::new("Europe/London")),
}));
// A name that cannot fit refuses the save, with the field named, and nothing is kept.
let (code, body) = json(
&app,
"PUT",
"/api/v1/shows/too-long",
serde_json::json!({
"name": "The Not Quite Late Enough Saturday Night Shipping Forecast Appreciation Society, Extended Edition, With Added Weather For The Inshore Waters And The Coastal Stations",
"colour_slot": 12, "layout": "poster", "hosts": ["Hamish Wallace"]
}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(body["error"].as_str().unwrap().contains("show name"));
let (code, _) = json(
&app,
"GET",
"/api/v1/shows/too-long",
serde_json::Value::Null,
)
.await;
assert_eq!(code, StatusCode::NOT_FOUND);
// The show, drawn on save.
let (code, body) = json(
&app,
"PUT",
"/api/v1/shows/saturday-night",
serde_json::json!({
"name": "Saturday Night Kettle",
"description": "Live music, requests and the Forth after dark, every Saturday from eight.",
"colour_slot": 3, "layout": "poster",
"slot": {"weekday": 6, "start": 1200, "minutes": 180},
"hosts": ["Shona Campbell", "Catriona MacKenzie-Buchanan"]
}),
)
.await;
assert_eq!(code, StatusCode::OK, "{body}");
let cards = &body["show"]["cards"];
for which in ["link", "post", "square"] {
let file = cards[which].as_str().unwrap();
assert!(dir.join("cards").join(file).exists(), "{file}");
}
assert!(
cards["alt"]
.as_str()
.unwrap()
.starts_with("Saturday Night Kettle, Saturdays 20:00 on Kettle FM")
);
// The card is served, as a PNG.
let (code, png) = send(
&app,
Request::get(format!("/cards/{}", cards["link"].as_str().unwrap()))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(code, StatusCode::OK);
assert!(png.starts_with(b"\x89PNG"));
// Series 3 from 5 September, ten planned; the season drafted at once.
let (code, body) = json(
&app,
"PUT",
"/api/v1/shows/saturday-night/series/3",
serde_json::json!({"starts_on": "2026-09-05", "planned_episodes": 10}),
)
.await;
assert_eq!(code, StatusCode::OK, "{body}");
assert_eq!(body["resolved"]["series"]["first"], "2026-09-05");
assert_eq!(body["resolved"]["series"]["last"], "2026-11-07");
assert!(
body["resolved"]["inherited"]
.as_array()
.unwrap()
.iter()
.any(|f| f == "hosts")
);
let (code, body) = json(
&app,
"POST",
"/api/v1/shows/saturday-night/series/3/episodes",
serde_json::json!({"count": 10}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{body}");
assert_eq!(body.as_array().unwrap().len(), 10);
let seventh = &body[6];
assert_eq!(seventh["episode"]["number"], 7);
assert!(seventh["episode"]["cards"]["square"].is_string());
// Episode 7: its title and guests.
let (code, body) = json(
&app,
"PATCH",
"/api/v1/shows/saturday-night/series/3/episodes/7",
serde_json::json!({"title": "Ghost Train night", "guests": ["Firth Lights", "Dr Una Robertson"]}),
)
.await;
assert_eq!(code, StatusCode::OK, "{body}");
assert_eq!(
body["episode"]["cards"]["alt"],
"Saturday Night Kettle, Series 3 Episode 7, Ghost Train night, Saturday 17 October 2026 at 20:00 BST on Kettle FM, with Shona Campbell and Catriona MacKenzie-Buchanan, guests Firth Lights and Dr Una Robertson. kettle.example/saturday-night/s3/e7"
);
// Not on air yet: not in the feed.
let (code, xml) = send(
&app,
Request::get("/feeds/saturday-night.xml")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(code, StatusCode::OK);
assert!(!String::from_utf8_lossy(&xml).contains("<item>"));
// On air: frozen.
let (code, body) = json(
&app,
"POST",
"/api/v1/shows/saturday-night/series/3/episodes/7/air",
serde_json::Value::Null,
)
.await;
assert_eq!(code, StatusCode::OK, "{body}");
assert_eq!(body["resolved"]["frozen"], true);
let (code, _) = json(
&app,
"PATCH",
"/api/v1/shows/saturday-night/series/3/episodes/7",
serde_json::json!({"title": "Rewritten"}),
)
.await;
assert_eq!(
code,
StatusCode::CONFLICT,
"what was broadcast is not rewritten"
);
// The recorder's files, tagged with the card as the last step.
std::fs::copy(
root().join("crates/feeds/tests/fixtures/tone.mp3"),
episodes_dir.join("saturday-night-s3e7.mp3"),
)
.unwrap();
std::fs::copy(
root().join("assets/carts-demo/ding-1.opus"),
episodes_dir.join("saturday-night-s3e7.opus"),
)
.unwrap();
let (code, body) = json(
&app,
"PUT",
"/api/v1/shows/saturday-night/series/3/episodes/7/files",
serde_json::json!({"enclosure": "saturday-night-s3e7.mp3", "opus": "saturday-night-s3e7.opus"}),
)
.await;
assert_eq!(code, StatusCode::OK, "{body}");
let square = std::fs::read(
dir.join("cards")
.join(body["episode"]["cards"]["square"].as_str().unwrap()),
)
.unwrap();
for f in ["saturday-night-s3e7.mp3", "saturday-night-s3e7.opus"] {
let back = weavesignal_feeds::tags::read(&episodes_dir.join(f)).unwrap();
assert_eq!(back.title.as_deref(), Some("Ghost Train night"));
assert_eq!(back.track, Some(7));
assert!(
back.cover.as_deref() == Some(&square[..]),
"{f} carries the card"
);
}
let (code, _) = json(
&app,
"PUT",
"/api/v1/shows/saturday-night/series/3/episodes/7/files",
serde_json::json!({"enclosure": "../../etc/passwd"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
let (code, _) = json(
&app,
"PUT",
"/api/v1/shows/saturday-night/series/3/episodes/7/files",
serde_json::json!({"enclosure": "saturday-night-s3e7.opus"}),
)
.await;
assert_eq!(
code,
StatusCode::UNPROCESSABLE_ENTITY,
"an Opus enclosure (ADR-0018)"
);
// The feed: the aired episode, both enclosures, its card.
let (code, xml) = send(
&app,
Request::get("/feeds/saturday-night.xml")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(code, StatusCode::OK);
let xml = String::from_utf8(xml).unwrap();
assert_eq!(xml.matches("<item>").count(), 1, "{xml}");
assert!(xml.contains("<title>Ghost Train night</title>"));
assert!(xml.contains(
r#"<enclosure url="https://kettle.example/media/episodes/saturday-night-s3e7.mp3""#
));
assert!(xml.contains(r#"type="audio/ogg""#) && xml.contains(r#"codecs="opus""#));
assert!(xml.contains(
r#"<podcast:source uri="https://kettle.example/media/episodes/saturday-night-s3e7.opus"/>"#
));
assert!(xml.contains(&format!(
r#"<itunes:image href="https://kettle.example/cards/{}"/>"#,
body["episode"]["cards"]["square"].as_str().unwrap()
)));
assert!(xml.contains(
r#"<podcast:person role="guest" group="cast">Dr Una Robertson</podcast:person>"#
));
assert!(
xml.contains(r#"<podcast:locked owner="podcasts@kettle.example">yes</podcast:locked>"#)
);
// The guid is kept: the same on the next fetch.
let guid = xml
.split("<podcast:guid>")
.nth(1)
.unwrap()
.split('<')
.next()
.unwrap()
.to_owned();
let (_, again) = send(
&app,
Request::get("/feeds/saturday-night.xml")
.body(Body::empty())
.unwrap(),
)
.await;
assert!(String::from_utf8(again).unwrap().contains(&guid));
// The enclosure is served with ranges, as podcast apps ask for it.
let (code, part) = send(
&app,
Request::get("/media/episodes/saturday-night-s3e7.mp3")
.header(header::RANGE, "bytes=0-9")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(code, StatusCode::PARTIAL_CONTENT);
assert_eq!(part.len(), 10);
let (code, _) = send(
&app,
Request::get("/media/episodes/..%2F..%2Fcards")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(code, StatusCode::NOT_FOUND);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_erased_guest_leaves_the_aired_card_and_its_old_files_go() {
// ADR-0122 through the routes: the aired episode's card is drawn again without her, and
// the old files that named her are deleted.
let url =
std::env::var("WEAVESIGNAL_TEST_DATABASE_URL").expect("WEAVESIGNAL_TEST_DATABASE_URL");
let pool = sqlx::PgPool::connect(&url).await.expect("connect");
let station = ensure_station(
&pool,
&Station::new(StationId::new_random(), "Kettle FM").unwrap(),
)
.await
.unwrap();
let dir =
PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("erase-{}", uuid::Uuid::new_v4()));
let app = router(Arc::new(ShowsState {
station,
db: Some(pool),
admin_token: Some(ADMIN.into()),
staff: Default::default(),
publishing: PublishingConfig {
public_url: "https://kettle.example".into(),
place: "Dunfermline".into(),
cards_dir: dir.join("cards"),
episodes_dir: dir.join("episodes"),
..PublishingConfig::default()
},
zone: Arc::new(weavesignal_control::zone::StationZone::new("Europe/London")),
}));
let ok = |(code, body): (StatusCode, serde_json::Value)| {
assert!(code.is_success(), "{code} {body}");
body
};
ok(json(
&app,
"PUT",
"/api/v1/shows/kettle-talk",
serde_json::json!({"name": "Kettle Talk", "colour_slot": 2, "layout": "slate",
"slot": {"weekday": 2, "start": 600, "minutes": 60}, "hosts": ["Shona Campbell"]}),
)
.await);
ok(json(
&app,
"PUT",
"/api/v1/shows/kettle-talk/series/1",
serde_json::json!({"starts_on": "2026-09-01"}),
)
.await);
ok(json(
&app,
"POST",
"/api/v1/shows/kettle-talk/series/1/episodes",
serde_json::json!({"count": 1}),
)
.await);
ok(json(
&app,
"PATCH",
"/api/v1/shows/kettle-talk/series/1/episodes/1",
serde_json::json!({"title": "The ferries", "guests": ["Morag Ross", "Gregor Stewart"]}),
)
.await);
let aired = ok(json(
&app,
"POST",
"/api/v1/shows/kettle-talk/series/1/episodes/1/air",
serde_json::Value::Null,
)
.await);
let old = aired["episode"]["cards"].clone();
assert!(old["alt"].as_str().unwrap().contains("Morag Ross"));
let old_link = old["link"].as_str().unwrap().to_owned();
assert!(dir.join("cards").join(&old_link).exists());
let people = ok(json(&app, "GET", "/api/v1/people", serde_json::Value::Null).await);
let morag = people
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "Morag Ross")
.unwrap()["id"]
.as_str()
.unwrap()
.to_owned();
let gone = ok(json(
&app,
"POST",
&format!("/api/v1/people/{morag}/erase"),
serde_json::Value::Null,
)
.await);
assert_eq!(gone["name"], "Morag Ross");
assert_eq!(gone["episodes"], 1);
assert_eq!(gone["cards_redrawn"], 1);
assert!(gone["card_files_deleted"].as_u64().unwrap() >= 1, "{gone}");
assert!(
!dir.join("cards").join(&old_link).exists(),
"the old card is deleted"
);
let series = ok(json(
&app,
"GET",
"/api/v1/shows/kettle-talk/series/1",
serde_json::Value::Null,
)
.await);
let ep = &series["episodes"][0];
let alt = ep["episode"]["cards"]["alt"].as_str().unwrap();
assert!(!alt.contains("Morag Ross"), "{alt}");
assert!(alt.contains("Gregor Stewart"), "{alt}");
assert_eq!(
ep["resolved"]["guests"],
serde_json::json!(["Gregor Stewart"])
);
let new_link = ep["episode"]["cards"]["link"].as_str().unwrap();
assert!(dir.join("cards").join(new_link).exists());
let (code, _) = json(
&app,
"POST",
&format!("/api/v1/people/{morag}/erase"),
serde_json::Value::Null,
)
.await;
assert_eq!(code, StatusCode::NOT_FOUND, "erased once");
}
/// The first `<guid>` of a feed's items (the episode's id), and the channel's `podcast:guid`.
fn guids(xml: &str) -> (String, String) {
let part = |after: &str, close: &str| {
let rest = xml.split(after).nth(1).expect(after);
let rest = rest.split_once('>').unwrap().1;
rest.split(close).next().unwrap().to_owned()
};
(
part("<item>", "</guid>")
.split("<guid")
.nth(1)
.map(|g| g.split_once('>').unwrap().1.to_owned())
.unwrap_or_default(),
part("<podcast:guid", "</podcast:guid>"),
)
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn staff_correct_an_aired_episode_and_the_feed_keeps_its_id() {
// ADR-0177 through the routes, and ADR-0176 on the save routes: words the cards cannot
// draw are refused with the field named, and nothing is kept.
let url =
std::env::var("WEAVESIGNAL_TEST_DATABASE_URL").expect("WEAVESIGNAL_TEST_DATABASE_URL");
let pool = sqlx::PgPool::connect(&url).await.expect("connect");
let station = ensure_station(
&pool,
&Station::new(StationId::new_random(), "Kettle FM").unwrap(),
)
.await
.unwrap();
// A producer, signed in: the log names her, not the station key.
let account = weavesignal_model::staff::create_account(
&pool,
station.id,
"morag",
"Morag Ross",
weavesignal_model::staff::Role::Producer,
&weavesignal_model::staff::hash_password("a long enough phrase").unwrap(),
)
.await
.unwrap();
let token = weavesignal_model::staff::new_token();
weavesignal_model::staff::create_session(
&pool,
station.id,
account.id,
&weavesignal_model::staff::token_hash(&token),
weavesignal_control::hub::now_ms() + 3_600_000,
)
.await
.unwrap();
// Another producer, who did not make the episodes (ADR-0198).
let other = weavesignal_model::staff::create_account(
&pool,
station.id,
"gregor",
"Gregor Stewart",
weavesignal_model::staff::Role::Producer,
&weavesignal_model::staff::hash_password("another long phrase").unwrap(),
)
.await
.unwrap();
let other_token = weavesignal_model::staff::new_token();
weavesignal_model::staff::create_session(
&pool,
station.id,
other.id,
&weavesignal_model::staff::token_hash(&other_token),
weavesignal_control::hub::now_ms() + 3_600_000,
)
.await
.unwrap();
let staff = Arc::new(weavesignal_control::staff::Staff::new(Some(pool.clone())));
staff.restore(station.id).await;
let dir =
PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("fix-{}", uuid::Uuid::new_v4()));
let episodes_dir = dir.join("episodes");
std::fs::create_dir_all(&episodes_dir).unwrap();
let app = router(Arc::new(ShowsState {
station,
db: Some(pool),
admin_token: Some(ADMIN.into()),
staff,
publishing: PublishingConfig {
public_url: "https://kettle.example".into(),
place: "Dunfermline".into(),
cards_dir: dir.join("cards"),
episodes_dir: episodes_dir.clone(),
..PublishingConfig::default()
},
zone: Arc::new(weavesignal_control::zone::StationZone::new("Europe/London")),
}));
let ok = |(code, body): (StatusCode, serde_json::Value)| {
assert!(code.is_success(), "{code} {body}");
body
};
let base = "/api/v1/shows/kettle-talk";
let ep = format!("{base}/series/1/episodes/1");
// ADR-0176: a show name in a script the cards lack is refused, naming the field.
let (code, body) = json(
&app,
"PUT",
"/api/v1/shows/kanji",
serde_json::json!({"name": "\u{6f22}\u{5b57} Hour", "colour_slot": 1, "layout": "poster"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(
body["error"].as_str().unwrap().contains("show name"),
"{body}"
);
let (code, _) = json(&app, "GET", "/api/v1/shows/kanji", serde_json::Value::Null).await;
assert_eq!(code, StatusCode::NOT_FOUND, "nothing was kept");
ok(json(
&app,
"PUT",
base,
serde_json::json!({"name": "Kettle Talk", "colour_slot": 2, "layout": "slate",
"slot": {"weekday": 2, "start": 600, "minutes": 60}, "hosts": ["Shona Campbell"]}),
)
.await);
ok(json(
&app,
"PUT",
&format!("{base}/series/1"),
serde_json::json!({"starts_on": "2026-09-01"}),
)
.await);
// ... on an episode's title, a guest's name and a series' title too, and no guest is
// remembered from the refused save.
// Morag drafts them, so she may correct them once aired (ADR-0198).
ok(json_as(
&app,
&token,
"POST",
&format!("{base}/series/1/episodes"),
serde_json::json!({"count": 2}),
)
.await);
for (patch, field) in [
(
serde_json::json!({"title": "\u{4f60}\u{597d}"}),
"episode title",
),
(
serde_json::json!({"guests": ["\u{05e9}\u{05dc}\u{05d5}\u{05dd}"]}),
"guest name",
),
(
serde_json::json!({"hosts": ["\u{0928}\u{092e}"]}),
"host name",
),
] {
let (code, body) = json(&app, "PATCH", &ep, patch).await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(
body["error"].as_str().unwrap().contains(field),
"{field}: {body}"
);
}
let (code, body) = json(
&app,
"PUT",
&format!("{base}/series/1"),
serde_json::json!({"title": "\u{4f60}\u{597d}"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(
body["error"].as_str().unwrap().contains("series title"),
"{body}"
);
// Drafting an episode checks its first title and guests too, before it keeps either.
let (code, body) = json(
&app,
"POST",
&format!("{base}/series/1/episodes"),
serde_json::json!({"title": "Ferries", "guests": ["\u{05e9}\u{05dc}\u{05d5}\u{05dd}"]}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(
body["error"].as_str().unwrap().contains("guest name"),
"{body}"
);
let drafted = ok(json(
&app,
"GET",
&format!("{base}/series/1"),
serde_json::Value::Null,
)
.await);
assert_eq!(
drafted["episodes"].as_array().map(Vec::len),
Some(2),
"the refused draft kept no episode: {drafted}"
);
let people = ok(json(&app, "GET", "/api/v1/people", serde_json::Value::Null).await);
assert!(
people
.as_array()
.unwrap()
.iter()
.all(|p| p["name"] == "Shona Campbell"),
"no one from a refused save is remembered: {people}"
);
let body = ok(json(
&app,
"GET",
&ep.replace("/episodes/1", ""),
serde_json::Value::Null,
)
.await);
assert_eq!(
body["episodes"][0]["episode"]["title"], "",
"the episode is as it was"
);
// ADR-0188 through the routes: the kind is carried by the API.
assert_eq!(body["episodes"][0]["episode"]["kind"], "live");
let marked = ok(json(
&app,
"PATCH",
&ep,
serde_json::json!({"kind": "prerecorded"}),
)
.await);
assert_eq!(marked["episode"]["kind"], "prerecorded");
assert_eq!(marked["resolved"]["episode"]["kind"], "prerecorded");
let (code, _) = json(&app, "PATCH", &ep, serde_json::json!({"kind": "studio-b"})).await;
assert!(code.is_client_error(), "only live or prerecorded");
// Episode 1: title and guests as first typed, aired, its files published.
ok(json(
&app,
"PATCH",
&ep,
serde_json::json!({"title": "The ferrys", "guests": ["Morag Ross", "Gregor Stuart"]}),
)
.await);
// Not aired: nothing to correct yet.
let fix = serde_json::json!({
"title": "The ferries", "guests": ["Morag Ross", "Gregor Stewart"],
"reason": "Misspelt the title and Gregor's surname."
});
let (code, body) = json_as(
&app,
&token,
"POST",
&format!("{ep}/corrections"),
fix.clone(),
)
.await;
assert_eq!(code, StatusCode::CONFLICT, "{body}");
ok(json(&app, "POST", &format!("{ep}/air"), serde_json::Value::Null).await);
std::fs::copy(
root().join("crates/feeds/tests/fixtures/tone.mp3"),
episodes_dir.join("kettle-talk-s1e1.mp3"),
)
.unwrap();
let before = ok(json(
&app,
"PUT",
&format!("{ep}/files"),
serde_json::json!({"enclosure": "kettle-talk-s1e1.mp3"}),
)
.await);
let old_link = before["episode"]["cards"]["link"]
.as_str()
.unwrap()
.to_owned();
let feed = || async {
let (code, xml) = send(
&app,
Request::get("/feeds/kettle-talk.xml")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(code, StatusCode::OK);
String::from_utf8(xml).unwrap()
};
let xml = feed().await;
assert!(xml.contains("<title>The ferrys</title>") && xml.contains("Gregor Stuart"));
let ids = guids(&xml);
assert!(!ids.0.is_empty(), "{xml}");
// The old way is still closed: an aired episode refuses a plain edit.
let (code, _) = json(&app, "PATCH", &ep, serde_json::json!({"title": "x"})).await;
assert_eq!(code, StatusCode::CONFLICT);
// Refused corrections keep nothing: no bearer, no reason, no change, words undrawable.
let url = format!("{ep}/corrections");
let (code, _) = send(
&app,
Request::post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(fix.to_string()))
.unwrap(),
)
.await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, _) = json_as(
&app,
&token,
"POST",
&url,
serde_json::json!({"title": "The ferries"}),
)
.await;
assert!(code.is_client_error(), "a reason is needed");
let (code, body) = json_as(
&app,
&token,
"POST",
&url,
serde_json::json!({"title": "The ferrys", "reason": "No change at all."}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
let (code, body) = json_as(
&app,
&token,
"POST",
&url,
serde_json::json!({"guests": ["\u{4f60}\u{597d}"], "reason": "A guest in Chinese."}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(
body["error"].as_str().unwrap().contains("guest name"),
"{body}"
);
let log = ok(json(&app, "GET", &url, serde_json::Value::Null).await);
assert!(log.as_array().unwrap().is_empty(), "{log}");
// ADR-0198: a producer who did not make the episode may not correct it; nothing is kept.
let (code, body) = json_as(&app, &other_token, "POST", &url, fix.clone()).await;
assert_eq!(code, StatusCode::FORBIDDEN, "{body}");
assert!(
body["error"]
.as_str()
.unwrap()
.contains("made this episode"),
"{body}"
);
let log = ok(json(&app, "GET", &url, serde_json::Value::Null).await);
assert!(log.as_array().unwrap().is_empty(), "{log}");
// The correction: cards redrawn, files tagged again, feed item changed under the same id.
let done = ok(json_as(&app, &token, "POST", &url, fix).await);
assert_eq!(done["correction"]["made_by"], "morag", "{done}");
assert_eq!(
done["correction"]["reason"],
"Misspelt the title and Gregor's surname."
);
assert_eq!(done["correction"]["old_values"]["title"], "The ferrys");
assert_eq!(done["correction"]["new_values"]["title"], "The ferries");
assert_eq!(done["resolved"]["frozen"], true);
assert_eq!(done["resolved"]["episode"]["title"], "The ferries");
assert_eq!(done["files_retagged"], true, "{done}");
let alt = done["episode"]["cards"]["alt"].as_str().unwrap();
assert!(
alt.contains("The ferries") && alt.contains("Gregor Stewart"),
"{alt}"
);
let new_link = done["episode"]["cards"]["link"].as_str().unwrap();
assert_ne!(new_link, old_link, "a changed card has a new name");
assert!(dir.join("cards").join(new_link).exists());
let tags = weavesignal_feeds::tags::read(&episodes_dir.join("kettle-talk-s1e1.mp3")).unwrap();
assert_eq!(tags.title.as_deref(), Some("The ferries"));
let square = std::fs::read(
dir.join("cards")
.join(done["episode"]["cards"]["square"].as_str().unwrap()),
)
.unwrap();
assert!(
tags.cover.as_deref() == Some(&square[..]),
"the file carries the new card"
);
let xml = feed().await;
assert_eq!(xml.matches("<item>").count(), 1);
assert!(xml.contains("<title>The ferries</title>") && !xml.contains("The ferrys"));
assert!(xml.contains("Gregor Stewart") && !xml.contains("Gregor Stuart"));
assert_eq!(guids(&xml), ids, "the item's guid and the feed's are kept");
// The log names who and why, and keeps the frozen original.
let log = ok(json(&app, "GET", &url, serde_json::Value::Null).await);
assert_eq!(log.as_array().unwrap().len(), 1);
assert_eq!(log[0]["made_by"], "morag");
assert_eq!(
log[0]["old_values"]["guests"],
serde_json::json!(["Morag Ross", "Gregor Stuart"])
);
// A correction by the station key is logged as the station key.
let second = ok(json(
&app,
"POST",
&url,
serde_json::json!({"description": "The island ferries.", "reason": "Added a description."}),
)
.await);
assert_eq!(second["correction"]["made_by"], "station key");
// Erasing the misspelt guest redacts the log as well as what aired.
let people = ok(json(&app, "GET", "/api/v1/people", serde_json::Value::Null).await);
let stuart = people
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "Gregor Stuart")
.unwrap()["id"]
.as_str()
.unwrap()
.to_owned();
let gone = ok(json(
&app,
"POST",
&format!("/api/v1/people/{stuart}/erase"),
serde_json::Value::Null,
)
.await);
assert_eq!(gone["corrections_redacted"], 1, "{gone}");
let log = ok(json(&app, "GET", &url, serde_json::Value::Null).await);
assert_eq!(log[0]["redacted"], true);
assert!(!log.to_string().contains("Gregor Stuart"), "{log}");
assert_eq!(
log[0]["old_values"]["title"], "The ferrys",
"the rest of the original stays"
);
}