use autumn_web::config::AutumnConfig;
use autumn_web::test::{TestApp, TestClient, TestDb, TestResponse};
const MIGRATION_SQL: &str =
include_str!("../migrations/20260908005714_create_content_schema/up.sql");
fn app_routes() -> Vec<autumn_web::Route> {
{{crate_name}}::all_routes()
}
fn form(pairs: &[(&str, &str)]) -> String {
fn encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char);
}
b' ' => out.push('+'),
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
pairs
.iter()
.map(|(key, value)| format!("{}={}", encode(key), encode(value)))
.collect::<Vec<_>>()
.join("&")
}
#[test]
fn the_front_controller_is_the_only_wildcard_route() {
let routes = app_routes();
let wildcards: Vec<&str> = routes
.iter()
.map(|route| route.path)
.filter(|path| path.contains('*'))
.collect();
assert_eq!(
wildcards,
vec!["/{*path}"],
"a second wildcard route would shadow, or be shadowed by, the front controller"
);
}
#[test]
fn every_reserved_prefix_has_a_literal_route() {
let routes = app_routes();
let paths: Vec<&str> = routes.iter().map(|route| route.path).collect();
for reserved in [
"/login",
"/logout",
"/register",
"/search",
"/admin",
"/feed",
"/api/v1",
"/media/{slug}",
"/favicon.ico",
] {
assert!(
paths.contains(&reserved),
"`{reserved}` has no literal route, so the front controller would try to resolve \
it as content; mounted paths: {paths:?}"
);
}
}
fn route_sources() -> &'static [(&'static str, &'static str)] {
&[
("routes/site.rs", include_str!("../src/routes/site.rs")),
("routes/auth.rs", include_str!("../src/routes/auth.rs")),
("routes/front.rs", include_str!("../src/routes/front.rs")),
(
"routes/comments.rs",
include_str!("../src/routes/comments.rs"),
),
(
"routes/admin/mod.rs",
include_str!("../src/routes/admin/mod.rs"),
),
(
"routes/admin/posts.rs",
include_str!("../src/routes/admin/posts.rs"),
),
(
"routes/admin/terms.rs",
include_str!("../src/routes/admin/terms.rs"),
),
(
"routes/admin/comments.rs",
include_str!("../src/routes/admin/comments.rs"),
),
(
"routes/admin/media.rs",
include_str!("../src/routes/admin/media.rs"),
),
(
"routes/admin/users.rs",
include_str!("../src/routes/admin/users.rs"),
),
(
"routes/admin/settings.rs",
include_str!("../src/routes/admin/settings.rs"),
),
(
"routes/admin/appearance.rs",
include_str!("../src/routes/admin/appearance.rs"),
),
(
"routes/admin/tools.rs",
include_str!("../src/routes/admin/tools.rs"),
),
("theme.rs", include_str!("../src/theme.rs")),
]
}
#[test]
fn no_handler_holds_a_pool_connection_across_repository_calls() {
let sources = route_sources();
let mut offenders = Vec::new();
for (name, source) in sources {
for (line_no, line) in source.lines().enumerate() {
if line.contains("autumn_web::Db") && !line.trim_start().starts_with("//") {
offenders.push(format!("{name}:{}: {}", line_no + 1, line.trim()));
}
}
}
assert!(
offenders.is_empty(),
"these handlers take the `Db` extractor; use `Repos::with_conn` instead so the \
connection cannot be held across a repository call:\n{}",
offenders.join("\n")
);
}
#[test]
fn every_post_form_emits_a_csrf_token() {
let sources = route_sources();
let mut forms = 0_usize;
for (name, source) in sources {
let lines: Vec<&str> = source.lines().collect();
for (index, line) in lines.iter().enumerate() {
if !line.contains(r#"method="post""#) {
continue;
}
forms += 1;
let window = lines[index..lines.len().min(index + 8)].join("\n");
assert!(
window.contains("csrf.input()") || window.contains("chrome.csrf"),
"{name}:{} opens a POST form with no CSRF token:\n{window}",
index + 1
);
}
}
assert!(
forms >= 20,
"expected to scan the application's POST forms, found only {forms} — \
has the markup changed shape?"
);
}
#[test]
fn every_admin_route_is_under_the_admin_prefix() {
let routes = app_routes();
for route in &routes {
if route.name.contains("admin") {
assert!(
route.path.starts_with("/admin") || route.path.starts_with("/media"),
"`{}` looks like an admin handler but is mounted at `{}`",
route.name,
route.path
);
}
}
}
fn migration_statements() -> Vec<String> {
let without_comments: String = MIGRATION_SQL
.lines()
.filter(|line| !line.trim_start().starts_with("--"))
.collect::<Vec<_>>()
.join("\n");
without_comments
.split(';')
.map(|statement| statement.trim().to_owned())
.filter(|statement| !statement.is_empty())
.collect()
}
async fn try_execute(db: &TestDb, sql: &str) -> Result<(), String> {
use diesel_async::RunQueryDsl;
let mut conn = db.pool().get().await.expect("pool connection");
diesel::sql_query(sql)
.execute(&mut conn)
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
static SCHEMA: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
async fn apply_search_vector_fallback(db: &TestDb) {
db.execute_sql("ALTER TABLE posts ADD COLUMN search_vector tsvector")
.await;
db.execute_sql(
"CREATE FUNCTION posts_search_vector_refresh() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english'::regconfig, coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('english'::regconfig, coalesce(NEW.excerpt, '')), 'B') ||
setweight(to_tsvector('english'::regconfig, coalesce(NEW.body, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql",
)
.await;
db.execute_sql(
"CREATE TRIGGER posts_search_vector_trigger BEFORE INSERT OR UPDATE ON posts
FOR EACH ROW EXECUTE PROCEDURE posts_search_vector_refresh()",
)
.await;
}
async fn db_client() -> TestClient {
let db = TestDb::shared().await;
SCHEMA
.get_or_init(|| async {
for statement in migration_statements() {
if let Err(error) = try_execute(db, &statement).await {
let is_generated_column = statement.contains("GENERATED ALWAYS AS");
assert!(
is_generated_column,
"migration statement failed: {error}\nSQL: {statement}"
);
apply_search_vector_fallback(db).await;
}
}
})
.await;
db.execute_sql(
"TRUNCATE users, options, attachments, posts, post_meta, terms, post_terms, \
revisions, comments, menus, menu_items, widgets RESTART IDENTITY CASCADE",
)
.await;
assert!(
{{crate_name}}::repositories::PgSiteOptionRepository::invalidate_declared_caches(),
"the test cache backend cannot invalidate by namespace, so settings would leak \
between tests"
);
{{crate_name}}::bootstrap();
let mut config = AutumnConfig::default();
config.security.csrf.enabled = false;
config.security.submit_token.enabled = false;
config.security.rate_limit.trust_forwarded_headers = true;
TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.build()
}
async fn csrf_client() -> TestClient {
let db = TestDb::shared().await;
SCHEMA
.get_or_init(|| async {
for statement in migration_statements() {
if try_execute(db, &statement).await.is_err() {
apply_search_vector_fallback(db).await;
}
}
})
.await;
db.execute_sql(
"TRUNCATE users, options, attachments, posts, post_meta, terms, post_terms, \
revisions, comments, menus, menu_items, widgets RESTART IDENTITY CASCADE",
)
.await;
assert!(
{{crate_name}}::repositories::PgSiteOptionRepository::invalidate_declared_caches(),
"the test cache backend cannot invalidate by namespace, so settings would leak \
between tests"
);
{{crate_name}}::bootstrap();
let mut config = AutumnConfig::default();
config.security.csrf.enabled = true;
TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.build()
}
fn scrape_csrf(html: &str) -> (String, String) {
let marker = r#"<input type="hidden" name=""#;
let start = html
.find(marker)
.unwrap_or_else(|| panic!("no hidden CSRF input in the rendered form:\n{html}"))
+ marker.len();
let rest = &html[start..];
let name_end = rest.find('"').expect("field name is quoted");
let name = rest[..name_end].to_owned();
let value_marker = r#" value=""#;
let value_start =
rest.find(value_marker).expect("hidden input has a value") + value_marker.len();
let value_rest = &rest[value_start..];
let value_end = value_rest.find('"').expect("value is quoted");
(name, value_rest[..value_end].to_owned())
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn forms_carry_a_csrf_token_the_layer_accepts() {
let client = csrf_client().await;
let page = client.get("/register").send().await.assert_ok().text();
let (field, token) = scrape_csrf(&page);
assert_eq!(
field, "_csrf",
"the hidden field must use the configured `security.csrf.form_field`; \
a form that invents its own name submits a token nothing validates"
);
let accepted = client
.post("/register")
.form(&form(&[
("username", "owner"),
("email", "owner@example.com"),
("password", "correct-horse-battery-staple"),
(field.as_str(), token.as_str()),
]))
.send()
.await;
assert_eq!(
accepted.status,
303,
"a token-carrying submission must be accepted; body: {}",
accepted.text()
);
let refused = client
.post("/register")
.form(&form(&[
("username", "intruder"),
("email", "intruder@example.com"),
("password", "correct-horse-battery-staple"),
]))
.send()
.await;
assert!(
refused.status.is_client_error(),
"a submission with no CSRF token must be refused, got {}",
refused.status
);
}
fn session_cookie(resp: &TestResponse) -> String {
resp.header("set-cookie")
.expect("an authenticating response sets a session cookie")
.split(';')
.next()
.expect("cookie has a name=value pair")
.to_owned()
}
fn sign_out(client: &TestClient) {
client.log_out();
}
async fn sign_in(client: &TestClient, username: &str) -> String {
let resp = client
.post("/login")
.form(&form(&[
("username", username),
("password", "correct-horse-battery-staple"),
]))
.send()
.await;
assert_eq!(
resp.status,
303,
"sign-in should redirect; body was: {}",
resp.text()
);
session_cookie(&resp)
}
async fn register(client: &TestClient, username: &str) -> String {
let email = format!("{username}@example.com");
let resp = client
.post("/register")
.form(&form(&[
("username", username),
("email", &email),
("password", "correct-horse-battery-staple"),
]))
.send()
.await;
assert_eq!(
resp.status,
303,
"registration should redirect; body was: {}",
resp.text()
);
session_cookie(&resp)
}
fn settings_form(overrides: &[(&str, &str)]) -> String {
let mut fields: Vec<(&str, &str)> = vec![
("site_title", "Test Site"),
("tagline", ""),
("permalink_structure", "day_and_name"),
("posts_per_page", "10"),
("default_comment_status", "open"),
("comment_moderation", "on"),
("allow_guest_comments", "on"),
("active_theme", "default"),
("date_format", "%B %-d, %Y"),
("timezone", "UTC"),
];
for (key, value) in overrides {
match fields.iter_mut().find(|(k, _)| k == key) {
Some(slot) => slot.1 = value,
None => fields.push((key, value)),
}
}
form(&fields)
}
async fn import_export(client: &TestClient, cookie: &str, payload: &str) -> TestResponse {
const BOUNDARY: &str = "----cmsimport";
let body = format!(
"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"payload\"; \
filename=\"export.json\"\r\nContent-Type: application/json\r\n\r\n\
{payload}\r\n--{BOUNDARY}--\r\n"
);
client
.post("/admin/tools/import")
.header("cookie", cookie)
.header(
"content-type",
&format!("multipart/form-data; boundary={BOUNDARY}"),
)
.body(body)
.send()
.await
}
async fn lock_version_of(id: i64) -> i32 {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id)
.select({{crate_name}}::schema::posts::lock_version)
.first::<i32>(&mut conn)
.await
.expect("the post")
}
async fn edit_form(id: &impl std::fmt::Display, fields: &[(&str, &str)]) -> String {
let id: i64 = id.to_string().parse().expect("a post id");
let version = lock_version_of(id).await.to_string();
let mut all: Vec<(&str, &str)> = fields.to_vec();
all.push(("lock_version", version.as_str()));
form(&all)
}
async fn create_post(
client: &TestClient,
cookie: &str,
title: &str,
body: &str,
status: &str,
) -> i64 {
let resp = client
.post("/admin/content/post")
.header("cookie", cookie)
.form(&form(&[
("title", title),
("slug", ""),
("excerpt", ""),
("body", body),
("status", status),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(resp.status, 303, "create should redirect: {}", resp.text());
resp.header("location")
.expect("redirect to the editor")
.rsplit('/')
.next()
.expect("id is the last path segment")
.parse()
.expect("id is numeric")
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn create_with_a_blank_title_redisplays_the_editor_with_the_draft_intact() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for status in ["publish", "private", "future"] {
let mut fields = vec![
("title", " "),
("slug", ""),
("excerpt", ""),
("body", "A body nobody should lose."),
("status", status),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
];
if status == "future" {
fields.push(("publish_at", "2999-01-01T00:00"));
}
let resp = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
resp.assert_status(422);
assert!(
resp.header("location").is_none(),
"a rejected {status} submission must not redirect"
);
resp.assert_body_contains("A body nobody should lose.")
.assert_body_contains("must have a title");
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn update_with_a_blank_title_redisplays_the_editor_and_leaves_the_post_a_draft() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(
&client,
&cookie,
"Original Title",
"Original body.",
"draft",
)
.await;
let resp = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", " "),
("slug", ""),
("excerpt", ""),
("body", "An edit nobody should lose."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
],
)
.await,
)
.send()
.await;
resp.assert_status(422);
resp.assert_body_contains("An edit nobody should lose.")
.assert_body_contains("must have a title");
sign_out(&client);
let front = client.get("/original-title").send().await;
assert_eq!(
front.status, 404,
"the post must still be an unreachable draft"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_rejected_submission_does_not_launder_a_stale_lock_version() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Concurrent Post", "v1", "draft").await;
let stale_version = lock_version_of(id).await;
let bump = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Concurrent Post"),
("slug", ""),
("excerpt", ""),
("body", "v2, from someone else"),
("status", "draft"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
],
)
.await,
)
.send()
.await;
assert_eq!(bump.status, 303, "the concurrent edit should succeed");
assert_ne!(
lock_version_of(id).await,
stale_version,
"the concurrent edit must have advanced the lock version"
);
let stale_form = form(&[
("title", " "),
("slug", ""),
("excerpt", ""),
("body", "v1, edited but never saved"),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
("lock_version", &stale_version.to_string()),
]);
let rejected = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&stale_form)
.send()
.await;
rejected
.assert_status(422)
.assert_body_contains(&format!(r#"value="{stale_version}""#));
let resubmitted = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&stale_form.replacen("title=+++", "title=Fixed", 1))
.send()
.await;
resubmitted.assert_status(409);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn scheduling_with_a_past_or_missing_date_redisplays_the_editor() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let past = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Backdated"),
("slug", ""),
("excerpt", ""),
("body", "Scheduled body."),
("status", "future"),
("publish_at", "2000-01-01T00:00"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
past.assert_status(422)
.assert_body_contains("Scheduled body.")
.assert_body_contains("publish date in the future");
let missing = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Undated"),
("slug", ""),
("excerpt", ""),
("body", "Scheduled body."),
("status", "future"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
missing
.assert_status(422)
.assert_body_contains("Scheduled body.")
.assert_body_contains("Pick a publish date");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_first_registered_account_owns_the_site() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.get("/admin")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("Administrator")
.assert_body_contains("manage_options");
let subscriber = register(&client, "reader").await;
let resp = client
.get("/admin")
.header("cookie", &subscriber)
.send()
.await;
assert_eq!(
resp.status, 403,
"a signed-in account without the capability gets a 403, not a redirect"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_published_post_appears_on_the_front_page_and_a_draft_does_not() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(
&client,
&cookie,
"Published Post",
"Visible body.",
"publish",
)
.await;
create_post(&client, &cookie, "Draft Post", "Hidden body.", "draft").await;
sign_out(&client);
let home = client.get("/").send().await;
home.assert_ok().assert_body_contains("Published Post");
assert!(
!home.text().contains("Draft Post"),
"an unpublished post must not appear on the front page"
);
client
.get("/published-post")
.send()
.await
.assert_ok()
.assert_body_contains("Visible body.");
sign_out(&client);
let draft = client.get("/draft-post").send().await;
assert_eq!(draft.status, 404);
assert!(!draft.text().contains("Hidden body."));
client
.get("/draft-post")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("Hidden body.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_directly_published_post_has_a_publish_date() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Dated", "Body.", "publish").await;
sign_out(&client);
let page = client.get("/dated").send().await.assert_ok().text();
assert!(
page.contains("<time datetime="),
"a published post must render its date; page:\n{page}"
);
let posts: serde_json::Value = client.get("/api/v1/posts").send().await.assert_ok().json();
let first = &posts.as_array().expect("array")[0];
assert!(
!first["published_at"].is_null(),
"published_at must be set on a directly-published post: {first}"
);
let sitemap = client.get("/sitemap.xml").send().await.assert_ok().text();
assert!(sitemap.contains("<loc>"), "sitemap has no URLs:\n{sitemap}");
assert!(
sitemap.contains("/dated"),
"sitemap omits the published post:\n{sitemap}"
);
assert!(
sitemap.contains("<lastmod>"),
"sitemap entry has no lastmod:\n{sitemap}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn robots_disallows_everything_outside_production() {
let client = db_client().await;
let body = client.get("/robots.txt").send().await.assert_ok().text();
assert!(
body.contains("Disallow: /"),
"a non-production profile must not invite indexing; body: {body}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_status_state_machine_refuses_an_undeclared_edge() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Lifecycle", "Body.", "draft").await;
let ok = client
.post(&format!("/admin/content/post/{id}/status?to=publish"))
.header("cookie", &cookie)
.send()
.await;
assert_eq!(ok.status, 303);
let refused = client
.post(&format!("/admin/content/post/{id}/status?to=future"))
.header("cookie", &cookie)
.send()
.await;
assert!(
refused.status.is_client_error() || refused.status.is_server_error(),
"publish -> future is not a declared edge; got {}",
refused.status
);
client
.get("/lifecycle")
.send()
.await
.assert_ok()
.assert_body_contains("Lifecycle");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_guest_comment_is_held_for_moderation_until_approved() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Discuss", "Body.", "publish").await;
sign_out(&client);
let posted = client
.post(&format!("/comments/{post_id}"))
.form(&form(&[
("body", "First!"),
("author_name", "Guest"),
("author_email", "guest@example.com"),
]))
.send()
.await;
assert_eq!(posted.status, 303);
sign_out(&client);
let page = client.get("/discuss").send().await;
page.assert_ok();
assert!(
!page.text().contains("First!"),
"an unapproved comment must not be published"
);
let api: serde_json::Value = client
.get(&format!("/api/v1/posts/{post_id}/comments"))
.send()
.await
.assert_ok()
.json();
assert_eq!(api.as_array().map(Vec::len), Some(0));
let queue = client
.get("/admin/comments?status=pending")
.header("cookie", &cookie)
.send()
.await;
queue.assert_ok().assert_body_contains("First!");
let approved = client
.post("/admin/comments/1/status?to=approved")
.header("cookie", &cookie)
.send()
.await;
assert_eq!(approved.status, 303);
client
.get("/discuss")
.send()
.await
.assert_ok()
.assert_body_contains("First!")
.assert_body_contains("1 comment");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn approving_and_unapproving_keeps_the_comment_counter_exact() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Counted", "Body.", "publish").await;
sign_out(&client);
for n in 0..3 {
let body = format!("Comment {n}");
client
.post(&format!("/comments/{post_id}"))
.form(&form(&[
("body", &body),
("author_name", "Guest"),
("author_email", "guest@example.com"),
]))
.send()
.await;
}
for id in 1..=3 {
client
.post(&format!("/admin/comments/{id}/status?to=approved"))
.header("cookie", &cookie)
.send()
.await;
}
client
.get("/counted")
.send()
.await
.assert_body_contains("3 comments");
for _ in 0..2 {
client
.post("/admin/comments/1/status?to=spam")
.header("cookie", &cookie)
.send()
.await;
}
client
.get("/counted")
.send()
.await
.assert_body_contains("2 comments");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_contributor_cannot_publish_their_own_draft() {
let client = db_client().await;
let owner = register(&client, "owner").await;
client
.post("/admin/users")
.header("cookie", &owner)
.form(&form(&[
("username", "carla"),
("email", "carla@example.com"),
("password", "correct-horse-battery-staple"),
("role", "contributor"),
("display_name", "Carla"),
]))
.send()
.await
.assert_status(303);
let login = client
.post("/login")
.form(&form(&[
("username", "carla"),
("password", "correct-horse-battery-staple"),
]))
.send()
.await;
assert_eq!(login.status, 303);
let carla = session_cookie(&login);
let id = create_post(&client, &carla, "Contributor Draft", "Body.", "publish").await;
sign_out(&client);
let draft = client.get("/contributor-draft").send().await;
assert_eq!(
draft.status, 404,
"a contributor's post must not be published by posting `status=publish`"
);
let refused = client
.post(&format!("/admin/content/post/{id}/status?to=publish"))
.header("cookie", &carla)
.send()
.await;
assert_eq!(refused.status, 403);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_password_protected_post_withholds_its_body_until_unlocked() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let resp = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Secret"),
("slug", ""),
("excerpt", ""),
("body", "The hidden text."),
("status", "publish"),
("password", "letmein"),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await;
assert_eq!(resp.status, 303);
sign_out(&client);
let locked = client.get("/secret").send().await;
locked
.assert_ok()
.assert_body_contains("password protected");
assert!(
!locked.text().contains("The hidden text."),
"a protected post must not render its body"
);
let api: serde_json::Value = client.get("/api/v1/posts").send().await.assert_ok().json();
let first = &api.as_array().expect("array")[0];
assert_eq!(first["password_protected"], serde_json::json!(true));
assert!(
first.get("body").is_none(),
"the API must omit a protected post's body: {first}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_protected_post_withholds_its_excerpt_and_comments_everywhere() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let resp = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Sealed"),
("slug", ""),
("excerpt", ""),
(
"body",
"THE-SECRET-SENTENCE should never appear in a listing.",
),
("status", "publish"),
("password", "letmein"),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(resp.status, 303);
let post_id: i64 = resp
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.parse()
.expect("numeric id");
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "VISIBLE-ONLY-WHEN-UNLOCKED")]))
.send()
.await;
sign_out(&client);
for (label, path) in [
("home", "/"),
("atom feed", "/feed"),
("rss feed", "/feed/rss"),
("api list", "/api/v1/posts"),
("single page", "/sealed"),
] {
let body = client.get(path).send().await.text();
assert!(
!body.contains("THE-SECRET-SENTENCE"),
"{label} leaked the protected body: {path}"
);
assert!(
!body.contains("VISIBLE-ONLY-WHEN-UNLOCKED"),
"{label} leaked a protected post's comments: {path}"
);
}
assert_eq!(
client
.get(&format!("/api/v1/posts/{post_id}/comments"))
.send()
.await
.status,
404
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_protected_post_refuses_comments_until_unlocked() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let resp = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Sealed Thread"),
("slug", ""),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", "letmein"),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(resp.status, 303);
let post_id: i64 = resp
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.parse()
.expect("numeric id");
let refused = client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "SHOULD-NOT-BE-STORED")]))
.send()
.await;
assert_eq!(
refused.status, 403,
"a comment on a locked post must be refused, not stored approved"
);
client
.post(&format!("/unlock/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("password", "letmein")]))
.send()
.await
.assert_status(303);
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "ALLOWED-AFTER-UNLOCK")]))
.send()
.await
.assert_status(303);
let comments: serde_json::Value = client
.get(&format!("/api/v1/posts/{post_id}/comments"))
.send()
.await
.json();
let rendered = comments.to_string();
assert!(
!rendered.contains("SHOULD-NOT-BE-STORED"),
"the refused comment must not have been persisted: {rendered}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_post_and_a_page_cannot_take_the_same_bare_path() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "About", "Post body.", "publish").await;
client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "About"),
("slug", ""),
("excerpt", ""),
("body", "Page body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await
.assert_status(303);
sign_out(&client);
client
.get("/about")
.send()
.await
.assert_ok()
.assert_body_contains("Post body.");
client
.get("/about-2")
.send()
.await
.assert_ok()
.assert_body_contains("Page body.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_stale_editor_submission_is_refused() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Contested", "Original.", "publish").await;
let stale_version = "0";
let first = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&form(&[
("title", "Contested"),
("slug", "contested"),
("excerpt", ""),
("body", "First editor's text."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
("lock_version", stale_version),
]))
.send()
.await;
assert_eq!(first.status, 303, "the first save should succeed");
let second = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&form(&[
("title", "Contested"),
("slug", "contested"),
("excerpt", ""),
("body", "Second editor's text."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
("lock_version", stale_version),
]))
.send()
.await;
assert_eq!(
second.status, 409,
"a save built on a stale version must be refused, not applied"
);
sign_out(&client);
client
.get("/contested")
.send()
.await
.assert_body_contains("First editor's text.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_a_slug_an_existing_post_holds_does_not_abort_the_run() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "Imported",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{
"post_type": "page", "title": "About", "slug": "about",
"excerpt": "", "body": "Imported page.", "status": "publish",
"comment_status": "closed", "password": "", "author": "owner",
"published_at": null, "parent": null, "terms": []
},
{
"post_type": "post", "title": "Second", "slug": "second",
"excerpt": "", "body": "Imported post.", "status": "publish",
"comment_status": "open", "password": "", "author": "owner",
"published_at": null, "parent": null, "terms": []
}
]
})
.to_string();
create_post(&client, &cookie, "About", "Existing post.", "publish").await;
let result = import_export(&client, &cookie, payload.as_str()).await;
result.assert_ok().assert_body_contains("2 imported");
sign_out(&client);
client
.get("/about")
.send()
.await
.assert_ok()
.assert_body_contains("Existing post.");
client
.get("/about-2")
.send()
.await
.assert_ok()
.assert_body_contains("Imported page.");
client
.get("/second")
.send()
.await
.assert_ok()
.assert_body_contains("Imported post.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn export_preserves_password_protection_and_page_ancestry() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let parent = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Handbook"),
("slug", ""),
("excerpt", ""),
("body", "Parent."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await;
let parent_id = parent
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Chapter"),
("slug", ""),
("excerpt", ""),
("body", "Child."),
("status", "publish"),
("password", "shh"),
("taxonomy_names[post_tag]", ""),
("parent_id", parent_id.as_str()),
]))
.send()
.await
.assert_status(303);
let payload = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let parsed: serde_json::Value = serde_json::from_str(&payload).expect("export is valid JSON");
let chapter = parsed["posts"]
.as_array()
.expect("posts array")
.iter()
.find(|p| p["slug"] == serde_json::json!("chapter"))
.expect("the child page is exported");
assert_eq!(
chapter["password"],
serde_json::json!("shh"),
"the export must carry the password, or a restore publishes protected content"
);
assert_eq!(
chapter["parent"],
serde_json::json!("handbook"),
"the export must carry ancestry, or a restore flattens the page tree"
);
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "Guides"),
("slug", ""),
("description", ""),
]))
.send()
.await
.assert_status(303);
let parent_term: serde_json::Value = client
.get("/api/v1/terms?taxonomy=category")
.send()
.await
.assert_ok()
.json();
let parent_term_id = parent_term.as_array().expect("array")[0]["id"]
.as_i64()
.expect("term id")
.to_string();
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "Deep Dives"),
("slug", ""),
("description", ""),
("parent_id", parent_term_id.as_str()),
]))
.send()
.await
.assert_status(303);
let payload = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let parsed: serde_json::Value = serde_json::from_str(&payload).expect("export is valid JSON");
let child_term = parsed["terms"]
.as_array()
.expect("terms array")
.iter()
.find(|t| t["slug"] == serde_json::json!("deep-dives"))
.expect("the child term is exported");
assert_eq!(
child_term["parent"],
serde_json::json!("guides"),
"the export must carry taxonomy ancestry too"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn changing_the_permalink_structure_does_not_break_existing_urls() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Durable", "Body.", "publish").await;
client.get("/durable").send().await.assert_ok();
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[
("site_title", "Autumn CMS"),
("permalink_structure", "day_and_name"),
("front_page_id", ""),
]))
.send()
.await
.assert_status(303);
let year = chrono::Utc::now().format("%Y/%m/%d").to_string();
client
.get(&format!("/{year}/durable"))
.send()
.await
.assert_ok()
.assert_body_contains("Durable");
client
.get("/durable")
.send()
.await
.assert_ok()
.assert_body_contains("Durable");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn editing_a_post_records_a_restorable_revision() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Versioned", "First draft.", "publish").await;
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Versioned"),
("slug", "versioned"),
("excerpt", ""),
("body", "Second draft."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
],
)
.await,
)
.send()
.await
.assert_status(303);
client
.get("/versioned")
.send()
.await
.assert_body_contains("Second draft.");
let history = client
.get(&format!("/admin/content/post/{id}/revisions"))
.header("cookie", &cookie)
.send()
.await;
history.assert_ok().assert_body_contains("First draft.");
client
.post(&format!("/admin/content/post/{id}/revisions/1/restore"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
client
.get("/versioned")
.send()
.await
.assert_body_contains("First draft.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn tags_typed_into_the_editor_are_created_and_archived() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Tagged"),
("slug", ""),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", "Rust, Web Frameworks"),
]))
.send()
.await
.assert_status(303);
client
.get("/tag/rust")
.send()
.await
.assert_ok()
.assert_body_contains("Tagged");
client
.get("/tag/web-frameworks")
.send()
.await
.assert_ok()
.assert_body_contains("Tagged");
let terms: serde_json::Value = client
.get("/api/v1/terms?taxonomy=post_tag")
.send()
.await
.assert_ok()
.json();
let rust = terms
.as_array()
.expect("array")
.iter()
.find(|t| t["slug"] == serde_json::json!("rust"))
.expect("the rust tag exists");
assert_eq!(rust["post_count"], serde_json::json!(1));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn search_counts_and_paginates_only_visible_matches() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for n in 0..3 {
create_post(
&client,
&cookie,
&format!("Hidden {n}"),
"kumquat marmalade recipe",
"draft",
)
.await;
}
create_post(
&client,
&cookie,
"Visible",
"kumquat marmalade recipe",
"publish",
)
.await;
sign_out(&client);
let body = client
.get("/search?s=kumquat")
.send()
.await
.assert_ok()
.text();
assert!(body.contains("Visible"), "the published match must appear");
assert!(
!body.contains("Hidden"),
"a draft must not appear in public search results"
);
assert!(
body.contains("1 result"),
"the total must count only visible matches, not disclose hidden ones; body: {}",
&body[..body.len().min(2000)]
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_contributor_cannot_trash_their_published_post() {
let client = db_client().await;
let owner = register(&client, "owner").await;
client
.post("/admin/users")
.header("cookie", &owner)
.form(&form(&[
("username", "carla"),
("email", "carla@example.com"),
("password", "correct-horse-battery-staple"),
("role", "contributor"),
("display_name", "Carla"),
]))
.send()
.await
.assert_status(303);
sign_out(&client);
let login = client
.post("/login")
.form(&form(&[
("username", "carla"),
("password", "correct-horse-battery-staple"),
]))
.send()
.await;
let carla = session_cookie(&login);
sign_out(&client);
let id = create_post(&client, &carla, "Carla Draft", "Body.", "draft").await;
sign_out(&client);
client
.post(&format!("/admin/content/post/{id}/status?to=publish"))
.header("cookie", &owner)
.send()
.await
.assert_status(303);
sign_out(&client);
let published: serde_json::Value = client.get("/api/v1/posts").send().await.json();
assert_eq!(
published.as_array().map(Vec::len),
Some(1),
"the owner's publish must have taken effect: {published}"
);
sign_out(&client);
let refused = client
.post(&format!("/admin/content/post/{id}/status?to=trash"))
.header("cookie", &carla)
.send()
.await;
assert_eq!(
refused.status, 403,
"a contributor must not be able to trash content they can no longer edit"
);
sign_out(&client);
client.get("/carla-draft").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_year_shaped_slug_stays_reachable() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let resp = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "2026"),
("slug", ""),
("excerpt", ""),
("body", "A year in review."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(resp.status, 303);
sign_out(&client);
let archive = client.get("/2026").send().await;
archive.assert_ok().assert_body_contains("Archive: 2026");
let single = client.get("/2026-2").send().await;
single.assert_ok().assert_body_contains("A year in review.");
assert!(
!single.text().contains("Archive: 2026"),
"/2026-2 must be the post, not the archive"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn full_text_search_finds_a_post_by_its_body() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(
&client,
&cookie,
"Nothing Obvious",
"The quick brown fox jumps over the lazy dog.",
"publish",
)
.await;
create_post(
&client,
&cookie,
"Unrelated",
"Something else entirely.",
"publish",
)
.await;
let results = client.get("/search?s=brown+fox").send().await;
results.assert_ok().assert_body_contains("Nothing Obvious");
assert!(
!results.text().contains("Unrelated"),
"search must not match every post"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn export_and_import_round_trip_the_site_content() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Exported", "Body text.", "publish").await;
let export = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await;
export.assert_ok();
let payload = export.text();
assert!(payload.contains("Exported"), "export payload: {payload}");
let reimport = import_export(&client, &cookie, payload.as_str()).await;
reimport.assert_ok().assert_body_contains("already present");
let posts: serde_json::Value = client.get("/api/v1/posts").send().await.assert_ok().json();
assert_eq!(
posts.as_array().map(Vec::len),
Some(1),
"re-importing must not duplicate content"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_api_never_serves_a_password_hash() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Bylined", "Body.", "publish").await;
let authors = client.get("/api/v1/authors").send().await;
let body = authors.assert_ok().text();
assert!(
!body.contains("password_hash") && !body.contains("$2b$"),
"the authors endpoint leaked credential material: {body}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_page_is_addressed_by_its_ancestry() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let parent = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "About"),
("slug", ""),
("excerpt", ""),
("body", "Parent page."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await;
let parent_id = parent
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Team"),
("slug", ""),
("excerpt", ""),
("body", "Child page."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("parent_id", parent_id.as_str()),
]))
.send()
.await
.assert_status(303);
client
.get("/about/team")
.send()
.await
.assert_ok()
.assert_body_contains("Child page.");
sign_out(&client);
assert_eq!(client.get("/team").send().await.status, 404);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn unlocking_a_hidden_post_discloses_nothing() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let draft = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Unannounced Acquisition"),
("slug", ""),
("excerpt", ""),
("body", "Not for publication."),
("status", "draft"),
("password", ""),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await;
assert_eq!(draft.status, 303);
let draft_id = draft
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
sign_out(&client);
let probe = client
.post(&format!("/unlock/{draft_id}"))
.form(&form(&[("password", "guess")]))
.send()
.await;
assert_eq!(
probe.status, 404,
"an anonymous unlock of a draft must 404, not redirect"
);
assert!(
probe.header("location").is_none(),
"no Location header may carry a hidden post's permalink"
);
assert!(
!probe.text().contains("unannounced-acquisition"),
"the draft's slug must not appear in the response: {}",
probe.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn api_creation_suffixes_a_duplicate_slug_instead_of_failing() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let body = serde_json::json!({
"title": "Release Notes",
"body": "First.",
"status": "publish",
});
let first: serde_json::Value = client
.post("/api/v1/posts")
.header("cookie", &cookie)
.json(&body)
.send()
.await
.assert_status(201)
.json();
assert_eq!(first["slug"], serde_json::json!("release-notes"));
let second: serde_json::Value = client
.post("/api/v1/posts")
.header("cookie", &cookie)
.json(&body)
.send()
.await
.assert_status(201)
.json();
assert_eq!(
second["slug"],
serde_json::json!("release-notes-2"),
"the second creation must take a suffix, not a 500: {second}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_public_api_paginates_terms_and_comments() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for name in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"] {
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", name),
("slug", ""),
("description", ""),
("parent_id", ""),
]))
.send()
.await
.assert_status(303);
}
sign_out(&client);
let page_one: serde_json::Value = client
.get("/api/v1/terms?per_page=2")
.send()
.await
.assert_ok()
.json();
let page_one = page_one.as_array().expect("array");
assert_eq!(page_one.len(), 2, "per_page must be applied in SQL");
let page_two: serde_json::Value = client
.get("/api/v1/terms?per_page=2&page=2")
.send()
.await
.assert_ok()
.json();
let page_two = page_two.as_array().expect("array");
assert_eq!(page_two.len(), 2);
assert_ne!(
page_one[0]["id"], page_two[0]["id"],
"the second page must not repeat the first"
);
let clamped: serde_json::Value = client
.get("/api/v1/terms?per_page=100000")
.send()
.await
.assert_ok()
.json();
assert!(clamped.as_array().expect("array").len() <= 100);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_type_that_disables_comments_refuses_them_however_the_row_is_set() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Contact"),
("slug", ""),
("excerpt", ""),
("body", "Reach us here."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(created.status, 303);
let page_id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET comment_status = 'open' WHERE id = {page_id}"),
)
.await
.expect("force comment_status");
let refused = client
.post(&format!("/comments/{page_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "COMMENT-ON-A-PAGE")]))
.send()
.await;
assert_eq!(
refused.status,
403,
"a page must refuse comments: {}",
refused.text()
);
let page = client.get("/contact").send().await;
page.assert_ok();
assert!(
!page.text().contains("COMMENT-ON-A-PAGE"),
"nothing may have been stored"
);
assert!(
!page.text().contains("Post comment"),
"and the form must not be offered: {}",
page.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn deleting_a_comment_recounts_the_replies_it_cascades_away() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Threaded", "Body.", "publish").await;
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "Parent comment")]))
.send()
.await
.assert_status(303);
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "Reply one"), ("reply_to", "1")]))
.send()
.await
.assert_status(303);
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "Unrelated comment")]))
.send()
.await
.assert_status(303);
client
.get("/threaded")
.send()
.await
.assert_ok()
.assert_body_contains("3 comments");
client
.post("/admin/comments/1/delete")
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let page = client.get("/threaded").send().await;
page.assert_ok().assert_body_contains("1 comment");
assert!(
!page.text().contains("Reply one"),
"the cascaded reply must be gone"
);
assert!(
page.text().contains("Unrelated comment"),
"and the untouched comment must remain"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn guest_comment_submissions_are_throttled() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Flooded", "Body.", "publish").await;
sign_out(&client);
let mut throttled = false;
for i in 0..25 {
let resp = client
.post(&format!("/comments/{post_id}"))
.header("X-Forwarded-For", "198.51.100.23")
.form(&form(&[
("body", &format!("flood {i}")),
("author_name", "Guest"),
("author_email", "guest@example.com"),
]))
.send()
.await;
if resp.status == 429 {
throttled = true;
break;
}
}
assert!(
throttled,
"the comment endpoint must stop accepting after its per-minute bound"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn protected_post_password_attempts_are_throttled() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Members Only"),
("slug", ""),
("excerpt", ""),
("body", "The protected text."),
("status", "publish"),
("password", "correcthorse"),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await;
assert_eq!(created.status, 303);
let post_id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
sign_out(&client);
let mut throttled = false;
for i in 0..25 {
let resp = client
.post(&format!("/unlock/{post_id}"))
.header("X-Forwarded-For", "198.51.100.77")
.form(&form(&[("password", &format!("guess{i}"))]))
.send()
.await;
if resp.status == 429 {
throttled = true;
break;
}
}
assert!(
throttled,
"unlimited password guesses must not be available to one address"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_under_a_trashed_parent_keeps_the_child_reachable() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let parent = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Archive"),
("slug", ""),
("excerpt", ""),
("body", "Old parent."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await;
assert_eq!(parent.status, 303);
let parent_id = parent
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post(&format!("/admin/content/page/{parent_id}/status?to=trash"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let payload = serde_json::json!({
"version": 2,
"site_title": "Imported",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{
"post_type": "page", "title": "Orphan", "slug": "orphan",
"excerpt": "", "body": "Imported child.", "status": "publish",
"comment_status": "closed", "password": "", "author": "owner",
"published_at": null, "parent": "archive", "terms": []
}
]
})
.to_string();
let result = import_export(&client, &cookie, payload.as_str()).await;
result
.assert_ok()
.assert_body_contains("1 imported")
.assert_body_contains("could not keep its parent");
sign_out(&client);
client
.get("/orphan")
.send()
.await
.assert_ok()
.assert_body_contains("Imported child.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn search_does_not_reach_into_a_protected_body() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Quarterly Briefing"),
("slug", ""),
("excerpt", ""),
("body", "The acquisition of Zephyrine closes in March."),
("status", "publish"),
("password", "letmein"),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await
.assert_status(303);
sign_out(&client);
let hidden: serde_json::Value = client
.get("/api/v1/posts?search=Zephyrine")
.send()
.await
.assert_ok()
.json();
assert_eq!(
hidden.as_array().map(Vec::len),
Some(0),
"body text of a protected post must not be searchable: {hidden}"
);
let page = client.get("/search?q=Zephyrine").send().await;
page.assert_ok();
assert!(
!page.text().contains("Quarterly Briefing"),
"the front-end search must not surface it either"
);
let visible: serde_json::Value = client
.get("/api/v1/posts?search=Quarterly")
.send()
.await
.assert_ok()
.json();
assert_eq!(visible.as_array().map(Vec::len), Some(1));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn unapproving_a_parent_hides_and_uncounts_its_replies() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Moderated", "Body.", "publish").await;
for (body, reply_to) in [
("Parent comment", ""),
("Reply under parent", "1"),
("Unrelated comment", ""),
] {
let mut fields = vec![("body", body)];
if !reply_to.is_empty() {
fields.push(("reply_to", reply_to));
}
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await
.assert_status(303);
}
client
.get("/moderated")
.send()
.await
.assert_ok()
.assert_body_contains("3 comments");
client
.post("/admin/comments/1/status?to=spam")
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let page = client.get("/moderated").send().await;
page.assert_ok().assert_body_contains("1 comment");
assert!(
!page.text().contains("Reply under parent"),
"the orphaned reply must not render"
);
assert!(
page.text().contains("Unrelated comment"),
"an unrelated comment is untouched"
);
let api: serde_json::Value = client
.get(&format!("/api/v1/posts/{post_id}/comments"))
.send()
.await
.assert_ok()
.json();
assert_eq!(api.as_array().map(Vec::len), Some(1));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_thread_offers_a_reply_control_that_works() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Conversation", "Body.", "publish").await;
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "The first comment")]))
.send()
.await
.assert_status(303);
let page = client
.get("/conversation")
.header("cookie", &cookie)
.send()
.await;
let html = page.assert_ok().text();
assert!(
html.contains(r#"name="reply_to""#),
"the thread must render a control that supplies reply_to:\n{html}"
);
assert!(
html.contains(r#"value="1""#),
"and it must name the comment it replies to"
);
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "A threaded reply"), ("reply_to", "1")]))
.send()
.await
.assert_status(303);
client
.get("/conversation")
.send()
.await
.assert_ok()
.assert_body_contains("A threaded reply")
.assert_body_contains("aria-level=\"2\"");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_sitemap_carries_plain_permalinks() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Findable", "Body.", "publish").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("permalink_structure", "plain")]))
.send()
.await
.assert_status(303);
sign_out(&client);
let sitemap = client.get("/sitemap.xml").send().await;
let body = sitemap.assert_ok().text();
assert!(
body.contains(&format!("/?p={post_id}")),
"the plain permalink must be listed:\n{body}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_reslugged_page_is_still_idempotent() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "About", "Existing post.", "publish").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "Imported",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [{
"post_type": "page", "title": "About", "slug": "about",
"excerpt": "", "body": "Imported page.", "status": "publish",
"comment_status": "closed", "password": "", "author": "owner",
"published_at": null, "parent": null, "terms": []
}]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported");
sign_out(&client);
assert_eq!(
client.get("/about-3").send().await.status,
404,
"a second suffix means the retry duplicated the page"
);
client
.get("/about-2")
.send()
.await
.assert_ok()
.assert_body_contains("Imported page.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn menu_items_can_be_nested_and_only_within_their_own_menu() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for (name, location) in [("Main", "primary"), ("Footer", "")] {
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", name), ("location", location)]))
.send()
.await
.assert_status(303);
}
client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[("label", "Products"), ("url", "/products")]))
.send()
.await
.assert_status(303);
client
.post("/admin/appearance/menus/2/items")
.header("cookie", &cookie)
.form(&form(&[("label", "Legal"), ("url", "/legal")]))
.send()
.await
.assert_status(303);
let screen = client
.get("/admin/appearance")
.header("cookie", &cookie)
.send()
.await;
screen
.assert_ok()
.assert_body_contains(r#"name="parent_id""#)
.assert_body_contains("Under Products");
client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[
("label", "Widgets"),
("url", "/products/widgets"),
("parent_id", "1"),
]))
.send()
.await
.assert_status(303);
let foreign = client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[
("label", "Smuggled"),
("url", "/x"),
("parent_id", "2"),
]))
.send()
.await;
assert_eq!(foreign.status, 422, "body: {}", foreign.text());
let too_deep = client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[
("label", "Deeper"),
("url", "/y"),
("parent_id", "3"),
]))
.send()
.await;
assert_eq!(too_deep.status, 422, "body: {}", too_deep.text());
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_published_post_cannot_be_saved_without_a_title() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Has A Title", "Body.", "publish").await;
let refused = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", ""),
("slug", "has-a-title"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
],
)
.await,
)
.send()
.await;
assert_eq!(
refused.status,
422,
"an untitled live post must be refused: {}",
refused.text()
);
let post: serde_json::Value = client
.get(&format!("/api/v1/posts/{id}"))
.send()
.await
.assert_ok()
.json();
assert_eq!(post["title"], serde_json::json!("Has A Title"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_revision_is_attributed_to_the_editor_who_made_it() {
let client = db_client().await;
let owner = register(&client, "owner").await;
let id = create_post(&client, &owner, "Collaborative", "First draft.", "draft").await;
sign_out(&client);
let editor = register(&client, "editor").await;
client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "editor"),
("email", "editor@example.com"),
("display_name", "Editor"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &editor)
.form(
&edit_form(
&id,
&[
("title", "Collaborative"),
("slug", "collaborative"),
("excerpt", ""),
("body", "Edited by somebody else."),
("status", "draft"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
],
)
.await,
)
.send()
.await
.assert_status(303);
let history = client
.get(&format!("/admin/content/post/{id}/revisions"))
.header("cookie", &owner)
.send()
.await;
let html = history.assert_ok().text();
assert!(
html.contains("by Editor"),
"the revision must be credited to the account that made the edit:\n{html}"
);
assert!(
!html.contains("by Owner"),
"and not to the post's owner:\n{html}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_reply_to_a_hidden_parent_is_refused() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Stale Form", "Body.", "publish").await;
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "Parent comment")]))
.send()
.await
.assert_status(303);
client
.post("/admin/comments/1/status?to=spam")
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let refused = client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "Late reply"), ("reply_to", "1")]))
.send()
.await;
assert_eq!(
refused.status,
422,
"a reply to a hidden parent must be refused: {}",
refused.text()
);
let page = client.get("/stale-form").send().await;
page.assert_ok();
assert!(
!page.text().contains("Late reply"),
"and nothing may have been stored"
);
assert!(
page.text().contains("No comments yet"),
"the count must not have drifted: {}",
page.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_posts_api_pages_through_the_corpus() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for n in 1..=5 {
create_post(
&client,
&cookie,
&format!("Entry {n}"),
&format!("Body {n} about widgets."),
"publish",
)
.await;
}
sign_out(&client);
let titles = |v: &serde_json::Value| -> Vec<String> {
v.as_array()
.expect("array")
.iter()
.map(|p| p["title"].as_str().unwrap_or_default().to_owned())
.collect()
};
let page_one: serde_json::Value = client
.get("/api/v1/posts?per_page=2")
.send()
.await
.assert_ok()
.json();
let page_two: serde_json::Value = client
.get("/api/v1/posts?per_page=2&page=2")
.send()
.await
.assert_ok()
.json();
assert_eq!(titles(&page_one).len(), 2);
assert_eq!(titles(&page_two).len(), 2);
assert!(
titles(&page_one)
.iter()
.all(|t| !titles(&page_two).contains(t)),
"pages must not overlap: {:?} vs {:?}",
titles(&page_one),
titles(&page_two)
);
let search_two: serde_json::Value = client
.get("/api/v1/posts?search=widgets&per_page=2&page=2")
.send()
.await
.assert_ok()
.json();
assert_eq!(titles(&search_two).len(), 2);
let authors: serde_json::Value = client
.get("/api/v1/authors?per_page=1")
.send()
.await
.assert_ok()
.json();
assert_eq!(authors.as_array().map(Vec::len), Some(1));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_malformed_date_format_is_refused_rather_than_crashing_the_site() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Dated", "Body.", "publish").await;
let settings = |date_format: &str| settings_form(&[("date_format", date_format)]);
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings("%Y/%m/%d"))
.send()
.await
.assert_status(303);
sign_out(&client);
let dated = client
.get("/api/v1/posts")
.send()
.await
.assert_ok()
.json::<serde_json::Value>();
let url = dated.as_array().expect("array")[0]["url"]
.as_str()
.expect("url")
.to_owned();
client
.get(&url)
.send()
.await
.assert_ok()
.assert_body_contains(&chrono::Utc::now().format("%Y/%m/%d").to_string());
let refused = client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings("%"))
.send()
.await;
assert_eq!(
refused.status,
422,
"a malformed pattern must be refused, not silently reset to the default: {}",
refused.text()
);
sign_out(&client);
client.get("/").send().await.assert_ok();
client
.get(&url)
.send()
.await
.assert_ok()
.assert_body_contains(&chrono::Utc::now().format("%Y/%m/%d").to_string());
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_refused_private_api_creation_persists_nothing() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for _ in 0..3 {
let refused = client
.post("/api/v1/posts")
.header("cookie", &cookie)
.json(&serde_json::json!({
"title": "",
"body": "No title here.",
"status": "private",
}))
.send()
.await;
assert_eq!(refused.status, 422, "body: {}", refused.text());
}
let listing = client
.get("/admin/content/post")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
!listing.contains("No title here."),
"a refused creation must persist nothing:\n{listing}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn restoring_an_untitled_revision_onto_a_live_post_is_refused() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", ""),
("slug", "work-in-progress"),
("excerpt", ""),
("body", "First body."),
("status", "draft"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(created.status, 303, "body: {}", created.text());
let id: i64 = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.parse()
.expect("numeric id");
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Final Title"),
("slug", "work-in-progress"),
("excerpt", ""),
("body", "Second body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
],
)
.await,
)
.send()
.await
.assert_status(303);
let history = client
.get(&format!("/admin/content/post/{id}/revisions"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let untitled_revision = history
.match_indices("/revisions/")
.filter_map(|(at, _)| {
history[at + "/revisions/".len()..]
.split('/')
.next()
.and_then(|id| id.parse::<i64>().ok())
})
.min()
.expect("at least one revision");
let refused = client
.post(&format!(
"/admin/content/post/{id}/revisions/{untitled_revision}/restore"
))
.header("cookie", &cookie)
.send()
.await;
assert_eq!(
refused.status,
422,
"restoring an untitled revision onto a live post must be refused: {}",
refused.text()
);
let post: serde_json::Value = client
.get(&format!("/api/v1/posts/{id}"))
.send()
.await
.assert_ok()
.json();
assert_eq!(post["title"], serde_json::json!("Final Title"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_author_archive_needs_published_content() {
let client = db_client().await;
let owner = register(&client, "owner").await;
create_post(&client, &owner, "Owner Post", "Body.", "publish").await;
sign_out(&client);
register(&client, "lurker").await;
sign_out(&client);
assert_eq!(
client.get("/author/lurker").send().await.status,
404,
"an account with no public content must not have an archive"
);
let unknown = client.get("/author/nobody-at-all").send().await;
assert_eq!(
unknown.status, 404,
"and it must be indistinguishable from an account that does not exist"
);
client
.get("/author/owner")
.send()
.await
.assert_ok()
.assert_body_contains("Owner Post");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_refused_admin_creation_persists_nothing() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for _ in 0..3 {
let refused = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", ""),
("slug", "sneaky"),
("excerpt", ""),
("body", "Should not persist."),
("status", "private"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(refused.status, 422, "body: {}", refused.text());
}
let listing = client
.get("/admin/content/post")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
!listing.contains("Should not persist."),
"a refused creation must write nothing:\n{listing}"
);
sign_out(&client);
assert_eq!(client.get("/sneaky").send().await.status, 404);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_transition_is_attributed_to_the_acting_editor() {
let client = db_client().await;
let owner = register(&client, "owner").await;
let id = create_post(&client, &owner, "Awaiting Review", "Body.", "draft").await;
sign_out(&client);
let editor = register(&client, "editor").await;
client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "editor"),
("email", "editor@example.com"),
("display_name", "Editor"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
client
.post(&format!("/admin/content/post/{id}/status?to=publish"))
.header("cookie", &editor)
.send()
.await
.assert_status(303);
let history = client
.get(&format!("/admin/content/post/{id}/revisions"))
.header("cookie", &owner)
.send()
.await
.assert_ok()
.text();
assert!(
history.contains("draft → publish"),
"the transition must be in the history:\n{history}"
);
let transition_line = history
.split("draft → publish")
.nth(1)
.expect("text after the transition summary");
assert!(
transition_line.starts_with(" · draft · by Editor"),
"the transition must be credited to the acting editor: {}",
&transition_line[..transition_line.len().min(80)]
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_post_is_not_served_from_arbitrary_paths() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Hello", "Body.", "publish").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("permalink_structure", "day_and_name")]))
.send()
.await
.assert_status(303);
sign_out(&client);
let dated = chrono::Utc::now().format("%Y/%m/%d").to_string();
client
.get(&format!("/{dated}/hello"))
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
for alias in [
"/anything/hello",
"/2026/13/hello",
"/2026/00/hello",
"/2026/01/32/hello",
"/a/b/c/d/hello",
"/99/hello",
] {
assert_eq!(
client.get(alias).send().await.status,
404,
"`{alias}` is not a permalink and must not serve the post"
);
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn saving_a_post_keeps_assignments_the_editor_does_not_render() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Filed", "Body.", "publish").await;
let db = TestDb::shared().await;
try_execute(
db,
"INSERT INTO terms (taxonomy, name, slug, description) \
VALUES ('genre', 'Longform', 'longform', '')",
)
.await
.expect("insert custom term");
try_execute(
db,
&format!(
"INSERT INTO post_terms (post_id, term_id) \
SELECT {id}, id FROM terms WHERE slug = 'longform'"
),
)
.await
.expect("file the post under it");
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Filed"),
("slug", "filed"),
("excerpt", ""),
("body", "Edited body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", "rust"),
("comment_status", "open"),
],
)
.await,
)
.send()
.await
.assert_status(303);
let editor = client
.get(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(editor.contains("rust"), "the tag should have been applied");
let still_filed = try_execute(
db,
&format!(
"SELECT 1/COUNT(*) FROM post_terms pt JOIN terms t ON t.id = pt.term_id \
WHERE pt.post_id = {id} AND t.taxonomy = 'genre'"
),
)
.await;
assert!(
still_filed.is_ok(),
"the custom-taxonomy filing was deleted by an ordinary save: {still_filed:?}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_dated_permalink_matches_only_the_posts_own_date() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Hello", "Body.", "publish").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("permalink_structure", "day_and_name")]))
.send()
.await
.assert_status(303);
sign_out(&client);
let now = chrono::Utc::now();
let day = now.format("%Y/%m/%d").to_string();
let month = now.format("%Y/%m").to_string();
let year = now.format("%Y").to_string();
client
.get(&format!("/{day}/hello"))
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
client
.get(&format!("/{month}/hello"))
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
assert_eq!(
client.get(&format!("/{year}/hello")).send().await.status,
404
);
for alias in ["/2015/01/hello", "/2015/01/02/hello"] {
assert_eq!(
client.get(alias).send().await.status,
404,
"`{alias}` is not this post's permalink"
);
}
let unpadded = format!("/{}/{}/hello", now.format("%Y"), now.format("%-m"));
if unpadded != format!("/{month}/hello") {
assert_eq!(client.get(&unpadded).send().await.status, 404);
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn search_results_render_pagination() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("posts_per_page", "2")]))
.send()
.await
.assert_status(303);
for n in 1..=5 {
create_post(
&client,
&cookie,
&format!("Widget Report {n}"),
"All about widgets.",
"publish",
)
.await;
}
sign_out(&client);
let first = client.get("/search?s=widgets").send().await;
let html = first.assert_ok().text();
assert!(
html.contains("Older →"),
"the first page of results must link to the next:\n{html}"
);
assert!(
html.contains("s=widgets&page=2"),
"and that link must carry the search term:\n{html}"
);
let second = client.get("/search?s=widgets&page=2").send().await;
let html = second.assert_ok().text();
assert!(html.contains("← Newer"), "the second page must link back");
assert!(html.contains("Page 2 of 3"), "and say where it is:\n{html}");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn search_resolves_hierarchical_page_permalinks_from_the_batched_lookup() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
async fn create_page(
client: &TestClient,
cookie: &str,
title: &str,
slug: &str,
parent_id: Option<&str>,
) -> String {
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
(
"body",
"Zzflorb marks the search token this test looks for.",
),
("status", "publish"),
("password", ""),
];
if let Some(parent) = parent_id {
fields.push(("parent_id", parent));
}
let resp = client
.post("/admin/content/page")
.header("cookie", cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(
resp.status,
303,
"create page should redirect: {}",
resp.text()
);
resp.header("location")
.expect("redirect to the editor")
.rsplit('/')
.next()
.expect("id")
.to_owned()
}
let docs = create_page(&client, &cookie, "Docs Zzflorb", "docs", None).await;
let install = create_page(&client, &cookie, "Install Zzflorb", "install", Some(&docs)).await;
create_page(
&client,
&cookie,
"Setup A Zzflorb",
"setup-a",
Some(&install),
)
.await;
create_page(
&client,
&cookie,
"Setup B Zzflorb",
"setup-b",
Some(&install),
)
.await;
create_post(&client, &cookie, "Flat Zzflorb", "A flat post.", "publish").await;
sign_out(&client);
let html = client
.get("/search?s=zzflorb")
.send()
.await
.assert_ok()
.text();
assert!(
html.contains(r#"href="/docs/install/setup-a""#),
"the first sibling must resolve its full three-level path:\n{html}"
);
assert!(
html.contains(r#"href="/docs/install/setup-b""#),
"the second sibling, sharing the same parent, must resolve its own \
full path too -- not be dropped or given the first sibling's URL by \
the batched lookup:\n{html}"
);
assert!(
html.contains("Flat Zzflorb"),
"a flat post must still appear, unaffected by ancestry batching:\n{html}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_absurd_page_number_does_not_overflow() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Only Post", "Body.", "publish").await;
sign_out(&client);
for path in [
"/?page=18446744073709551615",
"/?page=9223372036854775808",
"/search?s=body&page=18446744073709551615",
"/api/v1/posts?page=18446744073709551615",
"/api/v1/terms?page=18446744073709551615",
"/api/v1/authors?page=18446744073709551615",
] {
let resp = client.get(path).send().await;
assert!(
resp.status.is_success(),
"`{path}` must be bounded rather than overflow, got {}",
resp.status
);
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_stale_plain_permalink_is_a_404() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Briefly", "Body.", "publish").await;
sign_out(&client);
client
.get(&format!("/?p={id}"))
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
try_execute(
TestDb::shared().await,
&format!("DELETE FROM posts WHERE id = {id}"),
)
.await
.expect("delete the post");
let stale = client.get(&format!("/?p={id}")).send().await;
assert_eq!(
stale.status,
404,
"a permalink that no longer resolves must say so: {}",
stale.text()
);
assert_eq!(
client.get(&format!("/archives/{id}")).send().await.status,
404
);
client.get("/").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_api_created_post_gets_an_initial_revision() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/api/v1/posts")
.header("cookie", &cookie)
.json(&serde_json::json!({
"title": "Through the API",
"body": "Body.",
"status": "draft"
}))
.send()
.await;
assert!(
created.status.is_success(),
"create: {} {}",
created.status,
created.text()
);
let revisions: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::get_result({{crate_name}}::schema::revisions::table.count(), &mut conn)
.await
.expect("the count")
};
assert_eq!(
revisions, 1,
"a post created through the API starts with the same history as one \
created in the admin"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_restore_keeps_each_file_with_its_uploader() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Illustrated", "Body.", "publish").await;
let _second = register(&client, "photographer").await;
let uploader_id: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq("photographer"))
.select({{crate_name}}::schema::users::id),
&mut conn,
)
.await
.expect("the account")
};
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO attachments (title, slug, mime_type, byte_size, alt_text, caption,
uploader_id)
VALUES ('Their Photo', 'their-photo', 'image/png', 1, '', '', {uploader_id})"
),
)
.await
.expect("seed the upload");
let cookie = sign_in(&client, "owner").await;
let payload = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
payload.contains("photographer"),
"the uploader travels:\n{payload}"
);
let fresh = db_client().await;
let _admin = register(&fresh, "owner").await;
let _second = register(&fresh, "photographer").await;
let cookie = sign_in(&fresh, "owner").await;
import_export(&fresh, &cookie, &payload).await.assert_ok();
let (owner_of_file, photographer): (Option<i64>, i64) = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let owner_of_file = RunQueryDsl::first(
{{crate_name}}::schema::attachments::table
.filter({{crate_name}}::schema::attachments::slug.eq("their-photo"))
.select({{crate_name}}::schema::attachments::uploader_id),
&mut conn,
)
.await
.expect("the attachment");
let photographer = RunQueryDsl::first(
{{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq("photographer"))
.select({{crate_name}}::schema::users::id),
&mut conn,
)
.await
.expect("the account");
(owner_of_file, photographer)
};
assert_eq!(
owner_of_file,
Some(photographer),
"the file comes back belonging to the account that uploaded it, not to \
whoever ran the import"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_terms_displayed_count_comes_from_the_posts() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Counted", "Body.", "publish").await;
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "Tallied"),
("slug", ""),
("description", ""),
]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO post_terms (post_id, term_id)
VALUES ({id}, (SELECT id FROM terms WHERE slug = 'tallied'))"
),
)
.await
.expect("file the post");
try_execute(
TestDb::shared().await,
"UPDATE terms SET post_count = 0 WHERE slug = 'tallied'",
)
.await
.expect("stale the counter");
let screen = client
.get("/admin/terms/category")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let row = screen
.split("Tallied")
.nth(1)
.expect("the term's row")
.to_owned();
assert!(
row.contains(">1<") || row.contains("> 1 <"),
"the screen an editor decides on must agree with the archive:\n{row}"
);
let api = client
.get("/api/v1/terms?taxonomy=category")
.send()
.await
.assert_ok()
.text();
let parsed: serde_json::Value = serde_json::from_str(&api).expect("the terms parse");
let counted = parsed
.as_array()
.expect("an array")
.iter()
.find(|term| term["slug"] == "tallied")
.expect("the term");
assert_eq!(
counted["post_count"], 1,
"the API publishes the count the archive would produce:\n{api}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn restoring_an_unregistered_taxonomy_keeps_its_hierarchy() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 5,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"attachments": [],
"posts": [],
"terms": [
{
"taxonomy": "dozing-plugin", "name": "Parent", "slug": "dozing-parent",
"description": "", "parent": null
},
{
"taxonomy": "dozing-plugin", "name": "Child", "slug": "dozing-child",
"description": "", "parent": "dozing-parent"
}
]
})
.to_string();
import_export(&client, &cookie, &payload).await.assert_ok();
let (child_parent, parent_id): (Option<i64>, i64) = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let child_parent = RunQueryDsl::first(
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("dozing-child"))
.select({{crate_name}}::schema::terms::parent_id),
&mut conn,
)
.await
.expect("the child");
let parent_id = RunQueryDsl::first(
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("dozing-parent"))
.select({{crate_name}}::schema::terms::id),
&mut conn,
)
.await
.expect("the parent");
(child_parent, parent_id)
};
assert_eq!(
child_parent,
Some(parent_id),
"only the file knows the shape of a taxonomy nothing registers, so the \
file is what to believe"
);
let flat = serde_json::json!({
"version": 5,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"attachments": [],
"posts": [],
"terms": [
{
"taxonomy": "post_tag", "name": "Rust", "slug": "rust",
"description": "", "parent": null
},
{
"taxonomy": "post_tag", "name": "Async", "slug": "async",
"description": "", "parent": "rust"
}
]
})
.to_string();
import_export(&client, &cookie, &flat).await.assert_ok();
let tag_parent: Option<i64> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("async"))
.select({{crate_name}}::schema::terms::parent_id),
&mut conn,
)
.await
.expect("the tag")
};
assert_eq!(
tag_parent, None,
"a registered flat taxonomy still refuses a parent, whatever a file says"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_backup_carries_content_of_an_unregistered_type() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Ordinary", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
"INSERT INTO posts (post_type, title, slug, excerpt, body, status, author_id,
comment_status, password, sticky, published_at)
VALUES ('dormant-plugin', 'Dormant', 'dormant', '', 'Still here.', 'publish', 1,
'closed', '', false, NOW())",
)
.await
.expect("seed the post");
try_execute(
TestDb::shared().await,
"INSERT INTO terms (taxonomy, name, slug, description, post_count)
VALUES ('dormant-taxonomy', 'Dormant term', 'dormant-term', '', 0)",
)
.await
.expect("seed the term");
let payload = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
payload.contains("dormant-plugin") && payload.contains("Still here."),
"the disabled plugin's content is in the backup:\n{payload}"
);
assert!(
payload.contains("dormant-taxonomy") && payload.contains("dormant-term"),
"and so is the taxonomy that described it"
);
assert!(payload.contains("Ordinary"));
let fresh = db_client().await;
let cookie = register(&fresh, "owner").await;
import_export(&fresh, &cookie, &payload).await.assert_ok();
let (body, term): (String, i64) = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let body = RunQueryDsl::first(
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::post_type.eq("dormant-plugin"))
.select({{crate_name}}::schema::posts::body),
&mut conn,
)
.await
.expect("the restored post");
let term = RunQueryDsl::get_result(
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::taxonomy.eq("dormant-taxonomy"))
.count(),
&mut conn,
)
.await
.expect("the count");
(body, term)
};
assert_eq!(body, "Still here.", "the content comes back");
assert_eq!(term, 1, "and so does the taxonomy that described it");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_unregistered_taxonomys_terms_are_not_linked() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Filed", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
"INSERT INTO terms (taxonomy, name, slug, description, post_count)
VALUES ('retired-plugin', 'Reference', 'reference', '', 1)",
)
.await
.expect("seed the orphaned term");
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO post_terms (post_id, term_id)
VALUES ({id}, (SELECT id FROM terms WHERE slug = 'reference'))"
),
)
.await
.expect("file it under the orphaned term");
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "News"),
("slug", ""),
("description", ""),
]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO post_terms (post_id, term_id)
VALUES ({id}, (SELECT id FROM terms WHERE slug = 'news'))"
),
)
.await
.expect("file it under the category");
sign_out(&client);
let page = client.get("/filed").send().await.assert_ok().text();
assert!(
page.contains("/category/news"),
"a registered taxonomy's term is still linked:\n{page}"
);
assert!(
!page.contains("Reference") && !page.contains("/retired-plugin/reference"),
"and one whose taxonomy nothing registers is not advertised at all"
);
assert_eq!(
client.get("/retired-plugin/reference").send().await.status,
404
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_seeded_composite_is_all_or_nothing() {
let client = db_client().await;
let _cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"CREATE FUNCTION refuse_second_item() RETURNS trigger AS $$
BEGIN
IF (SELECT count(*) FROM menu_items) >= 1 THEN
RAISE EXCEPTION 'interrupted';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql",
)
.await
.expect("install the function");
try_execute(
TestDb::shared().await,
"CREATE TRIGGER refuse_second_item BEFORE INSERT ON menu_items
FOR EACH ROW EXECUTE PROCEDURE refuse_second_item()",
)
.await
.expect("install the trigger");
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::seed::seed_site(&mut conn)
.await
.expect_err("the seed fails while the trigger refuses");
}
let menus: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::get_result({{crate_name}}::schema::menus::table.count(), &mut conn)
.await
.expect("the count")
};
assert_eq!(
menus, 0,
"a menu with no items must not be left for the next run to read as done"
);
try_execute(
TestDb::shared().await,
"DROP TRIGGER refuse_second_item ON menu_items",
)
.await
.expect("remove the trigger");
try_execute(TestDb::shared().await, "DROP FUNCTION refuse_second_item()")
.await
.expect("remove the function");
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::seed::seed_site(&mut conn)
.await
.expect("the retry repairs it");
}
let items: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::get_result({{crate_name}}::schema::menu_items::table.count(), &mut conn)
.await
.expect("the count")
};
assert!(items >= 2, "the whole menu is there, got {items} items");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_export_carries_revision_history() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Revised", "First draft.", "publish").await;
for body in ["Second draft.", "Third draft."] {
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Revised"),
("slug", "revised"),
("excerpt", ""),
("body", body),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
],
)
.await,
)
.send()
.await
.assert_status(303);
}
let payload = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
payload.contains("First draft.") && payload.contains("Second draft."),
"the snapshots travel, not just the current body:\n{payload}"
);
let fresh = db_client().await;
let cookie = register(&fresh, "owner").await;
import_export(&fresh, &cookie, &payload).await.assert_ok();
let bodies: Vec<String> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::load(
{{crate_name}}::schema::revisions::table
.order({{crate_name}}::schema::revisions::created_at.asc())
.select({{crate_name}}::schema::revisions::body),
&mut conn,
)
.await
.expect("the revisions")
};
assert!(
bodies.contains(&"First draft.".to_owned()) && bodies.contains(&"Second draft.".to_owned()),
"the history comes back: {bodies:?}"
);
assert!(
!bodies.iter().any(|body| body == "Third draft."),
"the restore's own snapshots do not survive beside it: {bodies:?}"
);
let authors: Vec<Option<i64>> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::load(
{{crate_name}}::schema::revisions::table.select({{crate_name}}::schema::revisions::author_id),
&mut conn,
)
.await
.expect("the authors")
};
assert!(
authors.iter().any(Option::is_some),
"a snapshot's editor is resolved back to an account: {authors:?}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_export_carries_custom_fields() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Annotated", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO post_meta (post_id, meta_key, meta_value) VALUES
({id}, 'subtitle', 'A plugin wrote this'),
({id}, 'reading_time', '4')"
),
)
.await
.expect("seed the fields");
let payload = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
payload.contains("A plugin wrote this") && payload.contains("reading_time"),
"the fields travel:\n{payload}"
);
assert!(
!payload.contains("_import_source_slug") && !payload.contains("_import_completed"),
"and the importer's own markers do not"
);
let fresh = db_client().await;
let cookie = register(&fresh, "owner").await;
import_export(&fresh, &cookie, &payload).await.assert_ok();
let restored: Vec<(String, String)> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::load(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::meta_key.ne_all({{crate_name}}::content::INTERNAL_META_KEYS))
.order({{crate_name}}::schema::post_meta::meta_key.asc())
.select((
{{crate_name}}::schema::post_meta::meta_key,
{{crate_name}}::schema::post_meta::meta_value,
)),
&mut conn,
)
.await
.expect("the fields")
};
assert_eq!(
restored,
vec![
("reading_time".to_owned(), "4".to_owned()),
("subtitle".to_owned(), "A plugin wrote this".to_owned()),
],
"both come back, and nothing else does"
);
import_export(&fresh, &cookie, &payload).await.assert_ok();
let total: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::get_result(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::meta_key.ne_all({{crate_name}}::content::INTERNAL_META_KEYS))
.count(),
&mut conn,
)
.await
.expect("the count")
};
assert_eq!(total, 2, "a second import does not duplicate them");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_featured_image_has_to_be_an_image() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Illustrated", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
"INSERT INTO attachments (title, slug, mime_type, byte_size, alt_text, caption)
VALUES ('Spreadsheet', 'spreadsheet-aaaa', 'text/csv', 1, '', ''),
('Picture', 'picture-bbbb', 'image/png', 1, '', '')",
)
.await
.expect("seed the library");
let ids = async |mime: &'static str| -> i64 {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::attachments::table
.filter({{crate_name}}::schema::attachments::mime_type.eq(mime))
.select({{crate_name}}::schema::attachments::id),
&mut conn,
)
.await
.expect("the attachment")
};
let csv = ids("text/csv").await;
let png = ids("image/png").await;
let editor = client
.get(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(editor.contains("Picture"), "an image is offered");
assert!(
!editor.contains("Spreadsheet"),
"and a file the post cannot render is not:\n{editor}"
);
let refused = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Illustrated"),
("slug", "illustrated"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("featured_media_id", &csv.to_string()),
],
)
.await,
)
.send()
.await;
assert_ne!(refused.status, 303, "a non-image must be refused");
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Illustrated"),
("slug", "illustrated"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("featured_media_id", &png.to_string()),
],
)
.await,
)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn saving_a_published_post_does_not_move_its_date() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Dated", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET published_at = TIMESTAMP '2026-03-04 05:06:07' WHERE id = {id}"),
)
.await
.expect("stamp a precise date");
let stored = async || -> Option<chrono::NaiveDateTime> {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::posts::table
.find(id)
.select({{crate_name}}::schema::posts::published_at),
&mut conn,
)
.await
.expect("the post")
};
let editor = client
.get(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let prefilled = editor
.split("name=\"publish_at\"")
.nth(1)
.and_then(|rest| rest.split("value=\"").nth(1))
.and_then(|rest| rest.split('"').next())
.expect("the prefilled date")
.to_owned();
assert_eq!(
prefilled, "2026-03-04T05:06",
"the control renders minute precision, which is the whole problem"
);
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Dated, retitled"),
("slug", "dated"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("publish_at", &prefilled),
],
)
.await,
)
.send()
.await
.assert_status(303);
assert_eq!(
stored().await,
Some(
chrono::NaiveDate::from_ymd_opt(2026, 3, 4)
.unwrap()
.and_hms_opt(5, 6, 7)
.unwrap()
),
"an untouched field must leave the publication record alone, seconds included"
);
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Dated, retitled"),
("slug", "dated"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("publish_at", "2026-03-05T09:30"),
],
)
.await,
)
.send()
.await
.assert_status(303);
assert_eq!(
stored().await,
Some(
chrono::NaiveDate::from_ymd_opt(2026, 3, 5)
.unwrap()
.and_hms_opt(9, 30, 0)
.unwrap()
),
"a date the editor actually typed is applied"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_nested_custom_type_item_is_still_reachable() {
{{crate_name}}::content_types::register_post_type({{crate_name}}::content_types::PostType {
hierarchical: true,
archive_base: "runbooks",
..{{crate_name}}::content_types::PostType::new("runbook", "Runbook", "Runbooks")
})
.expect("runbook registers cleanly");
let client = db_client().await;
let cookie = register(&client, "owner").await;
let item = async |title: &str, slug: &str, parent: &str| -> String {
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
(
"body",
if parent.is_empty() {
"The parent."
} else {
"The child."
},
),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
];
if !parent.is_empty() {
fields.push(("parent_id", parent));
}
let response = client
.post("/admin/content/runbook")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(response.status, 303, "create {title}: {}", response.text());
response
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let parent = item("Setup", "setup", "").await;
item("Wiring", "wiring", &parent).await;
sign_out(&client);
client
.get("/runbook/setup")
.send()
.await
.assert_ok()
.assert_body_contains("The parent.");
client
.get("/runbook/wiring")
.send()
.await
.assert_ok()
.assert_body_contains("The child.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_hierarchy_edit_settles_every_descendant_path() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let page = async |title: &str, slug: &str, parent: &str| -> i64 {
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
];
if !parent.is_empty() {
fields.push(("parent_id", parent));
}
let response = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(response.status, 303, "create {title}: {}", response.text());
response
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.parse()
.expect("a numeric id")
};
let old = page("Old", "old", "").await;
let status = page("Status", "status", &old.to_string()).await;
page("Deep", "deep", &status.to_string()).await;
let paths = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::page_paths_under(&mut conn, old)
.await
.expect("the paths")
};
let mut rendered: Vec<String> = paths.iter().map(|path| path.join("/")).collect();
rendered.sort();
assert_eq!(
rendered,
vec!["old/status".to_owned(), "old/status/deep".to_owned()],
"an edit at the root settles every path beneath it \
(the root's own path is top-level, so it has none of this shape)"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_export_carries_comments_and_an_import_restores_them() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Discussed", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
VALUES ({post_id}, NULL, 1, 'Owner', 'owner@example.com', '', '',
'The root', 'approved', NOW() - interval '3 hours')"
),
)
.await
.expect("seed the root");
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
VALUES
({post_id}, (SELECT id FROM comments WHERE body = 'The root'), NULL,
'Guest', 'guest@example.com', '', '', 'A nested reply', 'approved',
NOW() - interval '2 hours'),
({post_id}, NULL, NULL, 'Waiting', 'waiting@example.com', '', '',
'Held for moderation', 'pending', NOW() - interval '1 hours'),
({post_id}, NULL, NULL, 'Spammer', 'spam@example.com', '', '',
'Buy things', 'spam', NOW())"
),
)
.await
.expect("seed the rest");
let payload = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let parsed: serde_json::Value = serde_json::from_str(&payload).expect("the export parses");
let comments = &parsed["posts"][0]["comments"];
assert_eq!(
comments.as_array().map(Vec::len),
Some(3),
"three roots, with the reply nested under one of them:\n{payload}"
);
assert!(
payload.contains("A nested reply") && payload.contains("Buy things"),
"every state travels, not only the approved ones"
);
let fresh = db_client().await;
let cookie = register(&fresh, "owner").await;
let result = import_export(&fresh, &cookie, &payload).await;
result
.assert_ok()
.assert_body_contains("restored, moderation states and all");
let (approved, pending, spam, nested): (i64, i64, i64, i64) = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let count = async |conn: &mut _, status: &'static str| -> i64 {
RunQueryDsl::get_result(
{{crate_name}}::schema::comments::table
.filter({{crate_name}}::schema::comments::status.eq(status))
.count(),
conn,
)
.await
.expect("the count")
};
let approved = count(&mut conn, "approved").await;
let pending = count(&mut conn, "pending").await;
let spam = count(&mut conn, "spam").await;
let nested: i64 = RunQueryDsl::get_result(
{{crate_name}}::schema::comments::table
.filter({{crate_name}}::schema::comments::parent_id.is_not_null())
.count(),
&mut conn,
)
.await
.expect("the count");
(approved, pending, spam, nested)
};
assert_eq!(
(approved, pending, spam, nested),
(2, 1, 1, 1),
"every state and the nesting come back"
);
let count: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("discussed"))
.select({{crate_name}}::schema::posts::comment_count),
&mut conn,
)
.await
.expect("the post")
};
assert_eq!(count, 2, "the counter is rebuilt from the approved rows");
import_export(&fresh, &cookie, &payload).await.assert_ok();
let total: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::get_result({{crate_name}}::schema::comments::table.count(), &mut conn)
.await
.expect("the count")
};
assert_eq!(total, 4, "a second import is a no-op for comments");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_top_level_page_resolves_past_a_nested_namesake() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let page = async |title: &str, slug: &str, parent: &str| -> String {
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
(
"body",
if parent.is_empty() {
"Top level."
} else {
"Nested."
},
),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
];
if !parent.is_empty() {
fields.push(("parent_id", parent));
}
let response = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(response.status, 303, "create {title}: {}", response.text());
response
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let about_id = page("About", "about", "").await;
page("Team", "team", &about_id).await;
page("Team", "team", "").await;
sign_out(&client);
client
.get("/team")
.send()
.await
.assert_ok()
.assert_body_contains("Top level.");
client
.get("/about/team")
.send()
.await
.assert_ok()
.assert_body_contains("Nested.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_capacity_check_serializes_on_an_empty_container() {
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Main"), ("location", "primary")]))
.send()
.await
.assert_status(303);
let mut holder = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query("BEGIN")
.execute(&mut holder)
.await
.expect("begin");
diesel::sql_query("SELECT id FROM menus WHERE id = 1 FOR NO KEY UPDATE")
.execute(&mut holder)
.await
.expect("hold the menu row");
let blocked = tokio::time::timeout(std::time::Duration::from_millis(750), async {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::insert_menu_item(
&mut conn,
{{crate_name}}::models::NewMenuItem {
menu_id: 1,
parent_id: None,
label: "Home".to_owned(),
url: "/".to_owned(),
post_id: None,
term_id: None,
position: 0,
},
100,
)
.await
})
.await;
assert!(
blocked.is_err(),
"the insert must wait on the menu row, not race past an empty menu"
);
diesel::sql_query("COMMIT")
.execute(&mut holder)
.await
.expect("release the menu row");
let mut holder = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query("SELECT pg_advisory_lock(7717260, hashtext('primary'))")
.execute(&mut holder)
.await
.expect("hold the sidebar");
let blocked = tokio::time::timeout(std::time::Duration::from_millis(750), async {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::insert_widget(
&mut conn,
{{crate_name}}::models::NewWidget {
sidebar: "primary".to_owned(),
kind: "search".to_owned(),
title: "Search".to_owned(),
settings: serde_json::json!({}),
position: 0,
},
30,
)
.await
})
.await;
assert!(
blocked.is_err(),
"the insert must wait on the sidebar's lock, not race past an empty sidebar"
);
diesel::sql_query("SELECT pg_advisory_unlock(7717260, hashtext('primary'))")
.execute(&mut holder)
.await
.expect("release the sidebar");
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::insert_widget(
&mut conn,
{{crate_name}}::models::NewWidget {
sidebar: "primary".to_owned(),
kind: "search".to_owned(),
title: "Search".to_owned(),
settings: serde_json::json!({}),
position: 0,
},
30,
)
.await
.expect("the insert proceeds once the lock is free");
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_seed_leaves_an_occupied_menu_location_alone() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Main"), ("location", "primary")]))
.send()
.await
.assert_status(303);
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::seed::seed_site(&mut conn)
.await
.expect("the seed must not fail over a menu that is already there");
}
let at_primary: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::get_result(
{{crate_name}}::schema::menus::table
.filter({{crate_name}}::schema::menus::location.eq("primary"))
.count(),
&mut conn,
)
.await
.expect("the count")
};
assert_eq!(at_primary, 1, "and does not add a second one");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn managing_accounts_is_authorized_against_the_actors_current_row() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let _owner = register(&client, "owner").await;
let _second = register(&client, "second").await;
let _third = register(&client, "third").await;
try_execute(
TestDb::shared().await,
"UPDATE users SET role = 'administrator' WHERE username IN ('second', 'third')",
)
.await
.expect("promote");
let id_of = async |username: &'static str| -> i64 {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq(username))
.select({{crate_name}}::schema::users::id),
&mut conn,
)
.await
.expect("the account")
};
let actor_id = id_of("second").await;
let target_id = id_of("third").await;
try_execute(
TestDb::shared().await,
&format!("UPDATE users SET role = 'subscriber' WHERE id = {actor_id}"),
)
.await
.expect("demote the actor");
let updated = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::update_user(
&mut conn,
actor_id,
target_id,
{{crate_name}}::content::UserEdit {
role: {{crate_name}}::capabilities::Role::Administrator,
email: "third@example.com".to_owned(),
display_name: "Third".to_owned(),
bio: String::new(),
website: String::new(),
},
)
.await
};
assert_eq!(
updated
.expect_err("a revoked actor may not change roles")
.status(),
autumn_web::prelude::StatusCode::FORBIDDEN
);
let deleted = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::delete_user(&mut conn, actor_id, target_id).await
};
assert_eq!(
deleted
.expect_err("a revoked actor may not delete accounts")
.status(),
autumn_web::prelude::StatusCode::FORBIDDEN
);
let (role, present): (String, i64) = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let role: String = RunQueryDsl::first(
{{crate_name}}::schema::users::table
.find(target_id)
.select({{crate_name}}::schema::users::role),
&mut conn,
)
.await
.expect("the target");
let present: i64 =
RunQueryDsl::get_result({{crate_name}}::schema::users::table.find(target_id).count(), &mut conn)
.await
.expect("the count");
(role, present)
};
assert_eq!(role, "administrator", "the target is unchanged");
assert_eq!(present, 1, "and still there");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn creating_an_account_is_authorized_against_the_actors_current_row() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let _owner = register(&client, "owner").await;
let demoted = register(&client, "second").await;
try_execute(
TestDb::shared().await,
"UPDATE users SET role = 'administrator' WHERE username = 'second'",
)
.await
.expect("promote the second account");
let actor_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq("second"))
.select({{crate_name}}::schema::users::id),
&mut conn,
)
.await
.expect("the actor")
};
try_execute(
TestDb::shared().await,
&format!("UPDATE users SET role = 'subscriber' WHERE id = {actor_id}"),
)
.await
.expect("demote the actor");
let outcome = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::create_user_as(
&mut conn,
actor_id,
{{crate_name}}::models::NewUser {
username: "planted".to_owned(),
email: "planted@example.com".to_owned(),
password_hash: "x".repeat(60),
display_name: "Planted".to_owned(),
role: "administrator".to_owned(),
bio: String::new(),
website: String::new(),
},
)
.await
};
let error = outcome.expect_err("a demoted account may not mint an administrator");
assert_eq!(
error.status(),
autumn_web::prelude::StatusCode::FORBIDDEN,
"and is told so: {error}"
);
let planted: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::get_result(
{{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq("planted"))
.count(),
&mut conn,
)
.await
.expect("the count")
};
assert_eq!(planted, 0, "and no account was created");
try_execute(
TestDb::shared().await,
&format!("UPDATE users SET role = 'administrator' WHERE id = {actor_id}"),
)
.await
.expect("restore the role");
let ok = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::create_user_as(
&mut conn,
actor_id,
{{crate_name}}::models::NewUser {
username: "legitimate".to_owned(),
email: "legitimate@example.com".to_owned(),
password_hash: "x".repeat(60),
display_name: "Legitimate".to_owned(),
role: "editor".to_owned(),
bio: String::new(),
website: String::new(),
},
)
.await
};
ok.expect("an administrator may still create accounts");
let _ = demoted;
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_edit_path_refuses_an_actor_the_locked_row_forbids() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let owner = register(&client, "owner").await;
let id = create_post(&client, &owner, "Theirs", "Not yours.", "publish").await;
let _contributor = register(&client, "scribe").await;
try_execute(
TestDb::shared().await,
"UPDATE users SET role = 'contributor' WHERE username = 'scribe'",
)
.await
.expect("demote to contributor");
let contributor: {{crate_name}}::models::User = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq("scribe"))
.select({{crate_name}}::models::User::as_select()),
&mut conn,
)
.await
.expect("the contributor")
};
let outcome = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::update_post_with_revision(
&mut conn,
id,
{{crate_name}}::content::EditContext {
editor_id: contributor.id,
summary: "Edited".to_owned(),
expected_lock_version: None,
record_revision: false,
may_touch_hierarchy: false,
actor: Some(contributor.clone()),
},
|post| post.body = "Overwritten.".to_owned(),
)
.await
};
let error = outcome.expect_err("a Contributor may not edit somebody else's published post");
assert_eq!(
error.status(),
autumn_web::prelude::StatusCode::FORBIDDEN,
"and is told so rather than getting a generic failure: {error}"
);
let body: String = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::posts::table
.find(id)
.select({{crate_name}}::schema::posts::body),
&mut conn,
)
.await
.expect("the post")
};
assert_eq!(body, "Not yours.", "and nothing was written");
let owner_user: {{crate_name}}::models::User = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
RunQueryDsl::first(
{{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq("owner"))
.select({{crate_name}}::models::User::as_select()),
&mut conn,
)
.await
.expect("the owner")
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::update_post_with_revision(
&mut conn,
id,
{{crate_name}}::content::EditContext {
editor_id: owner_user.id,
summary: "Edited".to_owned(),
expected_lock_version: None,
record_revision: false,
may_touch_hierarchy: false,
actor: Some(owner_user.clone()),
},
|post| post.body = "Theirs to write.".to_owned(),
)
.await
.expect("an administrator may edit it");
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_menu_refuses_an_item_it_cannot_show() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Main"), ("location", "primary")]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
"INSERT INTO menu_items (menu_id, parent_id, label, url, position)
SELECT 1, NULL, 'Item ' || g, '/', 0 FROM generate_series(1, 100) AS g",
)
.await
.expect("fill the menu");
let refused = client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[
("label", "One too many"),
("url", "/late"),
("parent_id", ""),
("post_id", ""),
("term_id", ""),
("position", "0"),
]))
.send()
.await;
assert_ne!(
refused.status, 303,
"an item past the bound must be refused, not silently hidden"
);
assert!(
refused.text().contains("Remove one"),
"and must say how to make room:\n{}",
refused.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_sidebar_refuses_a_widget_it_cannot_show() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO widgets (sidebar, kind, title, settings, position)
SELECT 'primary', 'text', 'Widget ' || g, '{\"text\":\"x\"}'::jsonb, 0
FROM generate_series(1, 30) AS g",
)
.await
.expect("fill the sidebar");
let refused = client
.post("/admin/appearance/widgets")
.header("cookie", &cookie)
.form(&form(&[
("kind", "text"),
("title", "One too many"),
("text", "Invisible."),
("count", "5"),
("taxonomy", "category"),
("position", "0"),
]))
.send()
.await;
assert_ne!(
refused.status, 303,
"a widget past the bound must be refused, not silently hidden"
);
assert!(
refused.text().contains("Remove one"),
"and must say how to make room:\n{}",
refused.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_menu_slug_race_takes_the_next_suffix() {
use diesel_async::RunQueryDsl;
let client = db_client().await;
let _cookie = register(&client, "owner").await;
let mut competitor = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query("BEGIN")
.execute(&mut competitor)
.await
.expect("begin");
diesel::sql_query("INSERT INTO menus (name, slug, location) VALUES ('Main', 'main', '')")
.execute(&mut competitor)
.await
.expect("the competing insert");
let creating = tokio::spawn(async move {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::replace_menu_at_location(&mut conn, "Main", "").await
});
wait_for_a_blocked_backend().await;
diesel::sql_query("COMMIT")
.execute(&mut competitor)
.await
.expect("commit");
creating
.await
.expect("the task")
.expect("a lost slug race is not a conflict to report");
let slugs: Vec<String> = {
use diesel::QueryDsl as _;
use diesel::prelude::ExpressionMethods as _;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel_async::RunQueryDsl::load(
{{crate_name}}::schema::menus::table
.order({{crate_name}}::schema::menus::id.asc())
.select({{crate_name}}::schema::menus::slug),
&mut conn,
)
.await
.expect("the menus")
};
assert_eq!(
slugs,
vec!["main".to_owned(), "main-2".to_owned()],
"the loser takes the next suffix"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn two_menus_may_share_a_name() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for location in ["primary", "footer"] {
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Main"), ("location", location)]))
.send()
.await
.assert_status(303);
}
let slugs: Vec<String> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::menus::table
.order({{crate_name}}::schema::menus::id.asc())
.select({{crate_name}}::schema::menus::slug)
.load(&mut conn)
.await
.expect("the menus")
};
assert_eq!(
slugs,
vec!["main".to_owned(), "main-2".to_owned()],
"the second menu takes a suffix rather than being refused"
);
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Main"), ("location", "primary")]))
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_download_is_named_with_its_extension() {
let db = TestDb::shared().await;
let _ = db_client().await;
let uploads = std::env::temp_dir().join(format!(
"cms-download-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&uploads).expect("create the blob store root");
let mut config = AutumnConfig::default();
config.security.csrf.enabled = false;
config.security.submit_token.enabled = false;
let store = autumn_web::storage::LocalBlobStore::new(
"default".to_owned(),
uploads.clone(),
"/_blobs".to_owned(),
std::time::Duration::from_secs(900),
autumn_web::storage::local::SigningKey::new(b"cms-download-test-key".to_vec()),
Vec::new(),
)
.expect("local blob store");
let client = TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.state_initializer(move |state| {
state.insert_extension::<autumn_web::storage::BlobStoreState>(
autumn_web::storage::BlobStoreState::new(std::sync::Arc::new(store)),
);
})
.build();
let cookie = register(&client, "owner").await;
let boundary = "----cmsdownload";
let payload = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; \
filename=\"report.csv\"\r\nContent-Type: text/csv\r\n\r\nid,name\r\n\
--{boundary}--\r\n"
);
client
.post("/admin/media")
.header("cookie", &cookie)
.header(
"content-type",
&format!("multipart/form-data; boundary={boundary}"),
)
.body(payload)
.send()
.await
.assert_status(303);
let slug: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = db.pool().get().await.expect("conn");
{{crate_name}}::schema::attachments::table
.select({{crate_name}}::schema::attachments::slug)
.first(&mut conn)
.await
.expect("the attachment")
};
assert!(
slug.ends_with(".csv"),
"the key keeps the extension: {slug}"
);
let served = client.get(&format!("/media/{slug}")).send().await;
let disposition = served
.assert_ok()
.header("content-disposition")
.expect("a disposition header");
assert!(
disposition.contains("attachment"),
"a csv is a download, not an inline render: {disposition}"
);
assert!(
disposition.contains("report.csv"),
"the saved file must keep its extension: {disposition}"
);
std::fs::remove_dir_all(&uploads).ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_second_file_part_is_refused() {
let db = TestDb::shared().await;
let _ = db_client().await; let uploads = std::env::temp_dir().join(format!(
"cms-upload-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&uploads).expect("create the blob store root");
let mut config = AutumnConfig::default();
config.security.csrf.enabled = false;
config.security.submit_token.enabled = false;
let store = autumn_web::storage::LocalBlobStore::new(
"default".to_owned(),
uploads.clone(),
"/_blobs".to_owned(),
std::time::Duration::from_secs(900),
autumn_web::storage::local::SigningKey::new(b"cms-upload-test-key".to_vec()),
Vec::new(),
)
.expect("local blob store");
let client = TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.state_initializer(move |state| {
state.insert_extension::<autumn_web::storage::BlobStoreState>(
autumn_web::storage::BlobStoreState::new(std::sync::Arc::new(store)),
);
})
.build();
let cookie = register(&client, "owner").await;
let boundary = "----cmsboundary";
let part = |name: &str, body: &str| {
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; \
filename=\"{name}\"\r\nContent-Type: text/plain\r\n\r\n{body}\r\n"
)
};
let payload = format!(
"{}{}--{boundary}--\r\n",
part("one.txt", "first"),
part("two.txt", "second")
);
let refused = client
.post("/admin/media")
.header("cookie", &cookie)
.header(
"content-type",
&format!("multipart/form-data; boundary={boundary}"),
)
.body(payload)
.send()
.await;
assert_eq!(
refused.status,
422,
"a second file part must be refused: {}",
refused.text()
);
let library = client
.get("/admin/media")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(!library.contains("one.txt"));
assert!(!library.contains("two.txt"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_export_round_trip_keeps_featured_media() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let db = TestDb::shared().await;
try_execute(
db,
"INSERT INTO attachments (title, slug, file, mime_type, byte_size, alt_text, caption) \
VALUES ('Cover', 'cover-image', \
'{\"provider_id\":\"default\",\"key\":\"media/cover-image\",\
\"content_type\":\"image/png\",\"byte_size\":1234}'::jsonb, \
'image/png', 1234, 'A cover', '')",
)
.await
.expect("insert attachment");
let id = create_post(&client, &cookie, "Illustrated", "Body.", "publish").await;
try_execute(
db,
&format!(
"UPDATE posts SET featured_media_id = (SELECT id FROM attachments \
WHERE slug = 'cover-image') WHERE id = {id}"
),
)
.await
.expect("attach the image");
let exported = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let payload: serde_json::Value = serde_json::from_str(&exported).expect("valid export JSON");
assert_eq!(payload["version"], serde_json::json!(5));
assert_eq!(
payload["attachments"][0]["slug"],
serde_json::json!("cover-image")
);
assert_eq!(
payload["attachments"][0]["file"]["key"],
serde_json::json!("media/cover-image"),
"the export must carry the blob handle: {}",
payload["attachments"][0]
);
let post = payload["posts"]
.as_array()
.expect("posts")
.iter()
.find(|p| p["slug"] == serde_json::json!("illustrated"))
.expect("the post is in the export");
assert_eq!(post["featured_media"], serde_json::json!("cover-image"));
let fresh = db_client().await;
let cookie = register(&fresh, "owner").await;
import_export(&fresh, &cookie, exported.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
let editor = fresh
.get("/admin/content/post/1")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
editor.contains("cover-image") || editor.contains("Cover"),
"the restored post must still name its featured image:\n{editor}"
);
let restored = fresh
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let restored: serde_json::Value = serde_json::from_str(&restored).expect("valid JSON");
assert_eq!(
restored["attachments"][0]["file"]["key"],
serde_json::json!("media/cover-image"),
"the import must restore the handle, not only the metadata: {}",
restored["attachments"][0]
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_crafted_category_id_from_another_taxonomy_is_ignored() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Ordinary", "Body.", "publish").await;
let db = TestDb::shared().await;
try_execute(
db,
"INSERT INTO terms (taxonomy, name, slug, description) \
VALUES ('shelf', 'Reference', 'reference', '')",
)
.await
.expect("insert the foreign term");
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "News"),
("slug", ""),
("description", ""),
("parent_id", ""),
]))
.send()
.await
.assert_status(303);
let categories: serde_json::Value = client
.get("/api/v1/terms?taxonomy=category")
.send()
.await
.assert_ok()
.json();
let news_id = categories.as_array().expect("array")[0]["id"]
.as_i64()
.expect("id")
.to_string();
let foreign_id = (news_id.parse::<i64>().expect("id") + 1).to_string();
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Ordinary"),
("slug", "ordinary"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
("taxonomies[category]", news_id.as_str()),
("taxonomies[category]", foreign_id.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
let filed = try_execute(
db,
&format!(
"SELECT 1/COUNT(*) FROM post_terms pt JOIN terms t ON t.id = pt.term_id \
WHERE pt.post_id = {id} AND t.taxonomy = 'category'"
),
)
.await;
assert!(filed.is_ok(), "the real category must have been applied");
let foreign = try_execute(
db,
&format!(
"SELECT 1/COUNT(*) FROM post_terms pt JOIN terms t ON t.id = pt.term_id \
WHERE pt.post_id = {id} AND t.taxonomy = 'shelf'"
),
)
.await;
assert!(
foreign.is_err(),
"a term from a taxonomy that does not apply to `post` must be ignored"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_excerpt_filter_reaches_rendered_output() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(
&client,
&cookie,
"Filtered",
"The body that becomes an excerpt.",
"publish",
)
.await;
{{crate_name}}::plugins::add_filter(
{{crate_name}}::plugins::Filter::TheExcerpt,
{{crate_name}}::plugins::DEFAULT_PRIORITY,
|excerpt| format!("{excerpt} [EXCERPT-FILTER-RAN]"),
);
sign_out(&client);
client
.get("/")
.send()
.await
.assert_ok()
.assert_body_contains("[EXCERPT-FILTER-RAN]");
client
.get("/feed")
.send()
.await
.assert_ok()
.assert_body_contains("[EXCERPT-FILTER-RAN]");
let api: serde_json::Value = client.get("/api/v1/posts").send().await.assert_ok().json();
assert!(
api.as_array().expect("array")[0]["excerpt"]
.as_str()
.unwrap_or_default()
.contains("[EXCERPT-FILTER-RAN]"),
"the API projection must apply the filter too: {api}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn checking_a_category_box_saves_the_post() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Categorised", "Body.", "publish").await;
for name in ["News", "Reviews"] {
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", name),
("slug", ""),
("description", ""),
("parent_id", ""),
]))
.send()
.await
.assert_status(303);
}
let categories: serde_json::Value = client
.get("/api/v1/terms?taxonomy=category")
.send()
.await
.assert_ok()
.json();
let ids: Vec<String> = categories
.as_array()
.expect("array")
.iter()
.map(|t| t["id"].as_i64().expect("id").to_string())
.collect();
assert_eq!(ids.len(), 2);
let mut fields = vec![
("title", "Categorised"),
("slug", "categorised"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
("comment_status", "open"),
("taxonomies[category]", ids[0].as_str()),
];
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&edit_form(&id, &fields).await)
.send()
.await
.assert_status(303);
fields.push(("taxonomies[category]", ids[1].as_str()));
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&edit_form(&id, &fields).await)
.send()
.await
.assert_status(303);
sign_out(&client);
for slug in ["news", "reviews"] {
client
.get(&format!("/category/{slug}"))
.send()
.await
.assert_ok()
.assert_body_contains("Categorised");
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_editor_offers_only_reachable_statuses() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Live", "Body.", "publish").await;
let editor = client
.get(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
editor.contains(r#"value="publish""#),
"staying put is an option"
);
assert!(
editor.contains(r#"value="draft""#),
"publish -> draft is declared"
);
assert!(
editor.contains(r#"value="private""#),
"publish -> private is declared"
);
assert!(
!editor.contains(r#"value="pending""#),
"publish -> pending is not a declared edge:\n{editor}"
);
assert!(
!editor.contains(r#"value="future""#),
"publish -> future is not a declared edge:\n{editor}"
);
let draft = create_post(&client, &cookie, "Unpublished", "Body.", "draft").await;
let editor = client
.get(&format!("/admin/content/post/{draft}"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
for value in ["draft", "pending", "publish", "private", "future"] {
assert!(
editor.contains(&format!(r#"value="{value}""#)),
"a draft can reach `{value}`"
);
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_approving_a_comment_is_idempotent() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Discussed", "Body.", "publish").await;
sign_out(&client);
client
.post(&format!("/comments/{post_id}"))
.form(&form(&[
("body", "Held for review"),
("author_name", "Guest"),
("author_email", "guest@example.com"),
]))
.send()
.await
.assert_status(303);
for _ in 0..3 {
client
.post("/admin/comments/1/status?to=approved")
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
}
sign_out(&client);
client
.get("/discussed")
.send()
.await
.assert_ok()
.assert_body_contains("1 comment")
.assert_body_contains("Held for review");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_export_round_trip_keeps_the_sticky_flag() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Pinned", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET sticky = true WHERE id = {id}"),
)
.await
.expect("pin the post");
let exported = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let payload: serde_json::Value = serde_json::from_str(&exported).expect("valid JSON");
assert_eq!(
payload["posts"][0]["sticky"],
serde_json::json!(true),
"the export must carry the flag: {}",
payload["posts"][0]
);
let fresh = db_client().await;
let cookie = register(&fresh, "owner").await;
import_export(&fresh, &cookie, exported.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
let restored = fresh
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let restored: serde_json::Value = serde_json::from_str(&restored).expect("valid JSON");
assert_eq!(
restored["posts"][0]["sticky"],
serde_json::json!(true),
"and the import must apply it: {}",
restored["posts"][0]
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_hook_that_registers_a_hook_does_not_deadlock() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
{{crate_name}}::plugins::add_action(
{{crate_name}}::plugins::Action::PostSaved,
{{crate_name}}::plugins::DEFAULT_PRIORITY,
|_| {
{{crate_name}}::plugins::add_filter(
{{crate_name}}::plugins::Filter::TheTitle,
{{crate_name}}::plugins::DEFAULT_PRIORITY,
|title| title,
);
},
);
let created = tokio::time::timeout(
std::time::Duration::from_secs(10),
create_post(&client, &cookie, "Re-entrant", "Body.", "publish"),
)
.await
.expect("saving must not hang while a listener registers another hook");
assert!(created > 0);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn saving_settings_applies_every_option() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[
("site_title", "Renamed Site"),
("tagline", "A new tagline"),
("posts_per_page", "7"),
]))
.send()
.await
.assert_status(303);
let screen = client
.get("/admin/settings")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(screen.contains("Renamed Site"));
assert!(screen.contains("A new tagline"));
assert!(
screen.contains(r#"value="7""#),
"the page size stuck:\n{screen}"
);
sign_out(&client);
client
.get("/")
.send()
.await
.assert_ok()
.assert_body_contains("Renamed Site");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_very_long_filename_still_uploads() {
let db = TestDb::shared().await;
let _ = db_client().await;
let uploads = std::env::temp_dir().join(format!(
"cms-longname-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&uploads).expect("create the blob store root");
let mut config = AutumnConfig::default();
config.security.csrf.enabled = false;
config.security.submit_token.enabled = false;
let store = autumn_web::storage::LocalBlobStore::new(
"default".to_owned(),
uploads.clone(),
"/_blobs".to_owned(),
std::time::Duration::from_secs(900),
autumn_web::storage::local::SigningKey::new(b"cms-upload-test-key".to_vec()),
Vec::new(),
)
.expect("local blob store");
let client = TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.state_initializer(move |state| {
state.insert_extension::<autumn_web::storage::BlobStoreState>(
autumn_web::storage::BlobStoreState::new(std::sync::Arc::new(store)),
);
})
.build();
let cookie = register(&client, "owner").await;
let long_name = format!("{}.txt", "a".repeat(500));
let boundary = "----cmsboundary";
let payload = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; \
filename=\"{long_name}\"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--{boundary}--\r\n"
);
let resp = client
.post("/admin/media")
.header("cookie", &cookie)
.header(
"content-type",
&format!("multipart/form-data; boundary={boundary}"),
)
.body(payload)
.send()
.await;
assert_eq!(
resp.status,
303,
"a long filename is a valid upload: {}",
resp.text()
);
client
.get("/admin/media")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("aaaa");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_rejected_duplicate_upload_leaves_no_orphaned_blob() {
let db = TestDb::shared().await;
let _ = db_client().await;
let uploads = std::env::temp_dir().join(format!(
"cms-orphan-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&uploads).expect("create the blob store root");
let mut config = AutumnConfig::default();
config.security.csrf.enabled = false;
config.security.submit_token.enabled = false;
let store = autumn_web::storage::LocalBlobStore::new(
"default".to_owned(),
uploads.clone(),
"/_blobs".to_owned(),
std::time::Duration::from_secs(900),
autumn_web::storage::local::SigningKey::new(b"cms-upload-test-key".to_vec()),
Vec::new(),
)
.expect("local blob store");
let client = TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.state_initializer(move |state| {
state.insert_extension::<autumn_web::storage::BlobStoreState>(
autumn_web::storage::BlobStoreState::new(std::sync::Arc::new(store)),
);
})
.build();
let cookie = register(&client, "owner").await;
let boundary = "----cmsboundary";
let part = |name: &str, body: &str| {
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; \
filename=\"{name}\"\r\nContent-Type: text/plain\r\n\r\n{body}\r\n"
)
};
let payload = format!(
"{}{}--{boundary}--\r\n",
part("first.txt", "the first payload"),
part("second.txt", "the second payload")
);
client
.post("/admin/media")
.header("cookie", &cookie)
.header(
"content-type",
&format!("multipart/form-data; boundary={boundary}"),
)
.body(payload)
.send()
.await
.assert_status(422);
for _ in 0..50 {
if count_files(&uploads) == 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert_eq!(
count_files(&uploads),
0,
"the first part's blob must not survive the rejection"
);
}
fn count_files(root: &std::path::Path) -> usize {
let Ok(entries) = std::fs::read_dir(root) else {
return 0;
};
entries
.filter_map(Result::ok)
.map(|entry| {
let path = entry.path();
if path.is_dir() { count_files(&path) } else { 1 }
})
.sum()
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_username_that_is_not_a_url_segment_is_refused() {
let client = db_client().await;
for bad in [
"alice/news",
"alice bloggs",
"alice?x",
"alice.news",
"alice%2f",
] {
let resp = client
.post("/register")
.form(&form(&[
("username", bad),
("email", "someone@example.com"),
("password", "correct-horse-battery-staple"),
]))
.send()
.await;
assert_ne!(
resp.status, 303,
"`{bad}` cannot be an author URL segment and must be refused"
);
}
let cookie = register(&client, "alice-bloggs").await;
create_post(&client, &cookie, "By Alice", "Body.", "publish").await;
sign_out(&client);
client
.get("/author/alice-bloggs")
.send()
.await
.assert_ok()
.assert_body_contains("By Alice");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_configured_front_page_stays_selected() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let front = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Welcome"),
("slug", "welcome"),
("excerpt", ""),
("body", "The front page."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
]))
.send()
.await;
assert_eq!(front.status, 303);
let front_id = front
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("front_page_id", front_id.as_str())]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
"INSERT INTO posts (post_type, title, slug, body, status, author_id, published_at) \
SELECT 'page', 'Filler ' || n, 'filler-' || n, '', 'publish', 1, now() \
FROM generate_series(1, 250) AS n",
)
.await
.expect("insert filler pages");
let screen = client
.get("/admin/settings")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
screen.contains(&format!(r#"value="{front_id}" selected"#))
|| screen.contains(&format!(r#"selected value="{front_id}""#))
|| (screen.contains("Welcome") && screen.contains(&format!(r#"value="{front_id}""#))),
"the configured front page must still be in the selector:\n{}",
&screen[..screen.len().min(4000)]
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn clearing_every_category_removes_the_post_from_its_archives() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Filed Then Not", "Body.", "publish").await;
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "News"),
("slug", ""),
("description", ""),
("parent_id", ""),
]))
.send()
.await
.assert_status(303);
let categories: serde_json::Value = client
.get("/api/v1/terms?taxonomy=category")
.send()
.await
.assert_ok()
.json();
let news = categories.as_array().expect("array")[0]["id"]
.as_i64()
.expect("id")
.to_string();
let post_id = &id;
let base = async |extra: Option<&str>| {
let mut fields = vec![
("title", "Filed Then Not"),
("slug", "filed-then-not"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", "rust"),
("comment_status", "open"),
];
if let Some(term) = extra {
fields.push(("taxonomies[category]", term));
}
edit_form(post_id, &fields).await
};
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&base(Some(news.as_str())).await)
.send()
.await
.assert_status(303);
client
.get("/category/news")
.send()
.await
.assert_ok()
.assert_body_contains("Filed Then Not");
let mut cleared = base(None).await;
cleared = cleared.replace(
"taxonomy_names%5Bpost_tag%5D=rust",
"taxonomy_names%5Bpost_tag%5D=",
);
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&cleared)
.send()
.await
.assert_status(303);
sign_out(&client);
let archive = client.get("/category/news").send().await;
archive.assert_ok();
assert!(
!archive.text().contains("Filed Then Not"),
"an emptied selection must unfile the post:\n{}",
archive.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_shortcode_that_registers_a_shortcode_does_not_deadlock() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
{{crate_name}}::shortcodes::add_shortcode("reentrant", |_| {
{{crate_name}}::shortcodes::add_shortcode("added-from-inside", |_| String::new());
"<span>expanded</span>".to_owned()
});
create_post(
&client,
&cookie,
"Shortcoded",
"Before [reentrant] after.",
"publish",
)
.await;
sign_out(&client);
let page = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.get("/shortcoded").send(),
)
.await
.expect("rendering must not hang while a handler registers another shortcode");
page.assert_ok().assert_body_contains("expanded");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_scheduled_publication_snapshots_the_scheduled_state() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Due Now", "Body.", "draft").await;
let db = TestDb::shared().await;
let due = chrono::NaiveDateTime::parse_from_str("2026-01-02 03:04:05", "%Y-%m-%d %H:%M:%S")
.expect("a fixed due date");
try_execute(
db,
&format!("UPDATE posts SET status = 'future', published_at = '{due}' WHERE id = {id}"),
)
.await
.expect("schedule the post");
assert!(
!{{crate_name}}::content::publish_due_post(
&mut db.pool().get().await.expect("connection"),
id,
None,
"publish",
)
.await
.expect("query"),
"the guard compares the observed publish date"
);
assert!(
{{crate_name}}::content::publish_due_post(
&mut db.pool().get().await.expect("connection"),
id,
Some(due),
"publish",
)
.await
.expect("publish"),
"the due post publishes"
);
let history = client
.get(&format!("/admin/content/post/{id}/revisions"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let after = history
.split("future → publish")
.nth(1)
.expect("the transition is recorded");
assert!(
after.starts_with(" · future"),
"the snapshot must record the scheduled state, not the published one: {}",
&after[..after.len().min(60)]
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_registered_custom_taxonomy_is_editable() {
{{crate_name}}::content_types::register_taxonomy({{crate_name}}::content_types::Taxonomy {
slug: "shelf",
singular: "Shelf",
plural: "Shelves",
hierarchical: true,
post_types: &["page"],
rewrite_base: "shelf",
})
.expect("shelf registers cleanly");
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/terms/shelf")
.header("cookie", &cookie)
.form(&form(&[
("name", "Reference"),
("slug", ""),
("description", ""),
("parent_id", ""),
]))
.send()
.await
.assert_status(303);
let terms: serde_json::Value = client
.get("/api/v1/terms?taxonomy=shelf")
.send()
.await
.assert_ok()
.json();
let shelf_id = terms.as_array().expect("array")[0]["id"]
.as_i64()
.expect("id")
.to_string();
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Handbook"),
("slug", "handbook"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303);
let id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
let editor = client
.get(&format!("/admin/content/page/{id}"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
editor.contains("Shelves") && editor.contains("taxonomies[shelf]"),
"the editor must render a control for a registered taxonomy:\n{editor}"
);
client
.post(&format!("/admin/content/page/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Handbook"),
("slug", "handbook"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomies[shelf]", shelf_id.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
let filed = try_execute(
TestDb::shared().await,
&format!(
"SELECT 1/COUNT(*) FROM post_terms pt JOIN terms t ON t.id = pt.term_id \
WHERE pt.post_id = {id} AND t.taxonomy = 'shelf'"
),
)
.await;
assert!(
filed.is_ok(),
"the custom taxonomy term must be attached: {filed:?}"
);
client
.post(&format!("/admin/content/page/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Handbook"),
("slug", "handbook"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
],
)
.await,
)
.send()
.await
.assert_status(303);
let still = try_execute(
TestDb::shared().await,
&format!(
"SELECT 1/COUNT(*) FROM post_terms pt JOIN terms t ON t.id = pt.term_id \
WHERE pt.post_id = {id} AND t.taxonomy = 'shelf'"
),
)
.await;
assert!(still.is_err(), "clearing the control must detach the term");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_save_cannot_create_unbounded_terms() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Tagged", "Body.", "publish").await;
let save = async |tags: &str| {
edit_form(
&id,
&[
("title", "Tagged"),
("slug", "tagged"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("comment_status", "open"),
("taxonomy_names[post_tag]", tags),
],
)
.await
};
let many: Vec<String> = (0..500).map(|n| format!("tag{n}")).collect();
let refused = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&save(&many.join(",")).await)
.send()
.await;
assert_eq!(refused.status, 422, "body: {}", refused.text());
let refused = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&save(&"a".repeat(500)).await)
.send()
.await;
assert_eq!(refused.status, 422, "body: {}", refused.text());
let terms: serde_json::Value = client
.get("/api/v1/terms?taxonomy=post_tag")
.send()
.await
.assert_ok()
.json();
assert_eq!(terms.as_array().map(Vec::len), Some(0));
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&save("rust, web, async").await)
.send()
.await
.assert_status(303);
let terms: serde_json::Value = client
.get("/api/v1/terms?taxonomy=post_tag")
.send()
.await
.assert_ok()
.json();
assert_eq!(terms.as_array().map(Vec::len), Some(3));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn scheduling_requires_a_future_date() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Was Live", "Body.", "publish").await;
client
.post(&format!("/admin/content/post/{id}/status?to=draft"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let save = async |publish_at: &str| {
let mut fields = vec![
("title", "Was Live"),
("slug", "was-live"),
("excerpt", ""),
("body", "Body."),
("status", "future"),
("password", ""),
("comment_status", "open"),
];
if !publish_at.is_empty() {
fields.push(("publish_at", publish_at));
}
edit_form(&id, &fields).await
};
for attempt in ["", "2020-01-01T09:00"] {
let refused = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&save(attempt).await)
.send()
.await;
assert_eq!(
refused.status,
422,
"`{attempt}` is not a future publish date: {}",
refused.text()
);
}
let future = (chrono::Utc::now() + chrono::Duration::days(7))
.format("%Y-%m-%dT%H:%M")
.to_string();
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&save(&future).await)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_admin_created_username_must_be_a_url_segment() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let refused = client
.post("/admin/users")
.header("cookie", &cookie)
.form(&form(&[
("username", "alice/news"),
("email", "alice@example.com"),
("password", "correct-horse-battery-staple"),
("role", "author"),
("display_name", "Alice"),
]))
.send()
.await;
assert_ne!(
refused.status,
303,
"the admin screen must apply the same rule as registration: {}",
refused.text()
);
client
.post("/admin/users")
.header("cookie", &cookie)
.form(&form(&[
("username", "alice-news"),
("email", "alice@example.com"),
("password", "correct-horse-battery-staple"),
("role", "author"),
("display_name", "Alice"),
]))
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_import_does_not_reparent_a_local_post_that_shares_a_slug() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for (title, slug, parent) in [
("Guides", "guides", None),
("Install", "install", Some("1")),
] {
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
("body", "Local content."),
("status", "publish"),
("password", ""),
];
if let Some(parent) = parent {
fields.push(("parent_id", parent));
}
client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await
.assert_status(303);
}
client
.get("/guides/install")
.send()
.await
.assert_ok()
.assert_body_contains("Local content.");
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"attachments": [],
"posts": [
{
"post_type": "page", "title": "Manuals", "slug": "manuals",
"excerpt": "", "body": "Imported parent.", "status": "publish",
"comment_status": "closed", "password": "", "author": "owner",
"published_at": null, "parent": null, "terms": [],
"sticky": false, "menu_order": 0
},
{
"post_type": "page", "title": "Install", "slug": "install",
"excerpt": "", "body": "Imported child.", "status": "publish",
"comment_status": "closed", "password": "", "author": "owner",
"published_at": null, "parent": "manuals", "terms": [],
"sticky": false, "menu_order": 0
}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported");
sign_out(&client);
client
.get("/guides/install")
.send()
.await
.assert_ok()
.assert_body_contains("Local content.");
client
.get("/manuals/install")
.send()
.await
.assert_ok()
.assert_body_contains("Imported child.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_status_endpoint_refuses_undated_scheduling() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let fresh = create_post(&client, &cookie, "Never Dated", "Body.", "draft").await;
let refused = client
.post(&format!("/admin/content/post/{fresh}/status?to=future"))
.header("cookie", &cookie)
.send()
.await;
assert_eq!(refused.status, 422, "body: {}", refused.text());
let was_live = create_post(&client, &cookie, "Was Live", "Body.", "publish").await;
client
.post(&format!("/admin/content/post/{was_live}/status?to=draft"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let refused = client
.post(&format!("/admin/content/post/{was_live}/status?to=future"))
.header("cookie", &cookie)
.send()
.await;
assert_eq!(refused.status, 422, "body: {}", refused.text());
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET published_at = now() + interval '7 days' WHERE id = {was_live}"),
)
.await
.expect("give it a future date");
client
.post(&format!("/admin/content/post/{was_live}/status?to=future"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn trashing_a_page_with_live_children_is_refused() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let parent = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Docs"),
("slug", "docs"),
("excerpt", ""),
("body", "Parent."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(parent.status, 303);
let parent_id = parent
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
let child = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Install"),
("slug", "install"),
("excerpt", ""),
("body", "Child."),
("status", "publish"),
("password", ""),
("parent_id", parent_id.as_str()),
]))
.send()
.await;
assert_eq!(child.status, 303);
let child_id = child
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.get("/docs/install")
.send()
.await
.assert_ok()
.assert_body_contains("Child.");
let refused = client
.post(&format!("/admin/content/page/{parent_id}/status?to=trash"))
.header("cookie", &cookie)
.send()
.await;
assert_eq!(refused.status, 422, "body: {}", refused.text());
client
.get("/docs/install")
.send()
.await
.assert_ok()
.assert_body_contains("Child.");
client
.post(&format!("/admin/content/page/{child_id}/status?to=trash"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
client
.post(&format!("/admin/content/page/{parent_id}/status?to=trash"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_import_does_not_reparent_a_local_term() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for name in ["Guides", "Reference"] {
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", name),
("slug", ""),
("description", ""),
("parent_id", ""),
]))
.send()
.await
.assert_status(303);
}
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"attachments": [],
"posts": [],
"terms": [
{"taxonomy": "category", "name": "Guides", "slug": "guides",
"description": "", "parent": null},
{"taxonomy": "category", "name": "Reference", "slug": "reference",
"description": "", "parent": "guides"}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok();
let orphaned = try_execute(
TestDb::shared().await,
"SELECT 1/COUNT(*) FROM terms WHERE slug = 'reference' AND parent_id IS NOT NULL",
)
.await;
assert!(
orphaned.is_err(),
"the import must not have reparented the local term"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_moderation_queue_paginates() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Busy", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, author_name, body, status, created_at) \
SELECT {post_id}, 'Guest', 'pending ' || n, 'pending', now() - (n || ' minutes')::interval \
FROM generate_series(1, 60) AS n"
),
)
.await
.expect("seed the queue");
let first = client
.get("/admin/comments?status=pending")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(
first.contains("pending 1<"),
"the newest comment is on page one"
);
assert!(
!first.contains("pending 60<"),
"the oldest must not be on page one — the queue is unbounded:\n{}",
&first[..first.len().min(2000)]
);
let second = client
.get("/admin/comments?status=pending&page=2")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
assert!(second.contains("pending 60<"), "the rest is on page two");
assert!(!second.contains("pending 1<"), "pages must not overlap");
assert!(
first.contains("Busy"),
"the queue names the post being discussed"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_import_cannot_introduce_an_inline_html_attachment() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 3,
"site_title": "Tampered",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [],
"attachments": [{
"slug": "payload", "title": "Payload", "mime_type": "text/html",
"byte_size": 10, "width": null, "height": null,
"alt_text": "", "caption": "",
"file": {"provider_id": "default", "key": "media/payload",
"content_type": "text/html", "byte_size": 10}
}]
})
.to_string();
let refused = import_export(&client, &cookie, payload.as_str()).await;
assert_eq!(
refused.status,
422,
"an unsupported media type must stop the restore: {}",
refused.text()
);
assert!(!{{crate_name}}::routes::admin::media::may_render_inline("text/html"));
assert!(!{{crate_name}}::routes::admin::media::may_render_inline(
"image/svg+xml"
));
assert!({{crate_name}}::routes::admin::media::may_render_inline("image/png"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_transition_is_authorized_against_the_locked_row() {
let client = db_client().await;
let owner = register(&client, "owner").await;
sign_out(&client);
let contributor = register(&client, "contributor").await;
client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "contributor"),
("email", "contributor@example.com"),
("display_name", "Contributor"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
let id = create_post(&client, &contributor, "Their Draft", "Body.", "draft").await;
client
.post(&format!("/admin/content/post/{id}/status?to=publish"))
.header("cookie", &owner)
.send()
.await
.assert_status(303);
let refused = client
.post(&format!("/admin/content/post/{id}/status?to=trash"))
.header("cookie", &contributor)
.send()
.await;
assert_eq!(refused.status, 403, "body: {}", refused.text());
sign_out(&client);
client
.get("/their-draft")
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn create_comment_rechecks_the_post_under_its_lock() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Closing", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET comment_status = 'closed' WHERE id = {post_id}"),
)
.await
.expect("close comments");
let refused = {{crate_name}}::content::create_comment(
&mut TestDb::shared().await.pool().get().await.expect("conn"),
{{crate_name}}::models::NewComment {
post_id,
parent_id: None,
author_id: None,
author_name: "Guest".to_owned(),
author_email: "guest@example.com".to_owned(),
author_url: String::new(),
author_ip: String::new(),
body: "Late comment".to_owned(),
status: "approved".to_owned(),
},
"",
)
.await;
assert!(
refused.is_err(),
"the insert must re-read the post rather than trusting the caller"
);
let page = client.get("/closing").send().await;
page.assert_ok();
assert!(!page.text().contains("Late comment"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_crafted_comment_submission_is_validated_server_side() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Rules", "Body.", "publish").await;
let empty = client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", " ")]))
.send()
.await;
assert_ne!(
empty.status,
303,
"an empty comment body must be refused: {}",
empty.text()
);
sign_out(&client);
let nameless = client
.post(&format!("/comments/{post_id}"))
.form(&form(&[("body", "Anonymous"), ("author_name", " ")]))
.send()
.await;
assert_ne!(
nameless.status,
303,
"a guest comment with no name must be refused: {}",
nameless.text()
);
let huge = "x".repeat(10_001);
let oversized = client
.post(&format!("/comments/{post_id}"))
.form(&form(&[
("body", huge.as_str()),
("author_name", "Guest"),
("author_email", "guest@example.com"),
]))
.send()
.await;
assert_ne!(
oversized.status, 303,
"a body past the declared cap must be refused"
);
let queue = client
.get("/admin/comments?status=approved")
.header("cookie", &cookie)
.send()
.await;
queue.assert_ok();
assert!(
!queue.text().contains("Anonymous"),
"no rejected submission may have been stored"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn restoring_a_revision_is_authorized_against_the_locked_row() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let owner = register(&client, "owner").await;
sign_out(&client);
let contributor = register(&client, "contributor").await;
client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "contributor"),
("email", "contributor@example.com"),
("display_name", "Contributor"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
let id = create_post(&client, &contributor, "Their Draft", "Original.", "draft").await;
client
.post(&format!("/admin/content/post/{id}/status?to=publish"))
.header("cookie", &owner)
.send()
.await
.assert_status(303);
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let actor: {{crate_name}}::models::User = {{crate_name}}::schema::users::table
.filter({{crate_name}}::schema::users::username.eq("contributor"))
.select({{crate_name}}::models::User::as_select())
.first(&mut conn)
.await
.expect("the contributor account");
let revision_id: i64 = {{crate_name}}::schema::revisions::table
.filter({{crate_name}}::schema::revisions::post_id.eq(id))
.select({{crate_name}}::schema::revisions::id)
.order({{crate_name}}::schema::revisions::id.asc())
.first(&mut conn)
.await
.expect("the initial revision");
let refused =
{{crate_name}}::content::restore_revision(&mut conn, id, revision_id, Some(actor.id), Some(&actor))
.await;
assert!(
refused.is_err(),
"the restore must re-check the capability against the locked row"
);
{{crate_name}}::content::restore_revision(&mut conn, id, revision_id, None, None)
.await
.expect("an actorless restore is the scheduler's, not a user's");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_terms_parent_must_belong_to_the_same_taxonomy() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/terms/post_tag")
.header("cookie", &cookie)
.form(&form(&[
("name", "Rust"),
("slug", ""),
("description", ""),
]))
.send()
.await
.assert_status(303);
let tags = client
.get("/admin/terms/post_tag")
.header("cookie", &cookie)
.send()
.await;
tags.assert_ok();
assert!(tags.text().contains("Rust"));
let refused = client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "Grafted"),
("slug", ""),
("description", ""),
("parent_id", "1"),
]))
.send()
.await;
assert_eq!(
refused.status,
422,
"a cross-taxonomy parent must be refused: {}",
refused.text()
);
let categories = client
.get("/admin/terms/category")
.header("cookie", &cookie)
.send()
.await;
categories.assert_ok();
assert!(
!categories.text().contains("Grafted"),
"the refused term must not have been stored"
);
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "Guides"),
("slug", ""),
("description", ""),
]))
.send()
.await
.assert_status(303);
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "Beginner"),
("slug", ""),
("description", ""),
("parent_id", "2"),
]))
.send()
.await
.assert_status(303);
client
.get("/admin/terms/category")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("Guides — ");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_media_library_is_paginated() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO attachments (title, slug, mime_type, byte_size, created_at)
SELECT 'File ' || lpad(g::text, 3, '0'),
'file-' || lpad(g::text, 3, '0'),
'application/pdf',
1024,
NOW() - (g || ' minutes')::interval
FROM generate_series(1, 50) AS g",
)
.await
.expect("seed the library");
let first = client
.get("/admin/media")
.header("cookie", &cookie)
.send()
.await;
first.assert_ok();
let first = first.text();
assert!(first.contains("File 001"), "the newest row leads page one");
assert!(first.contains("File 048"), "page one holds a full page");
assert!(
!first.contains("File 049"),
"page one must stop at the page size rather than render the whole table"
);
assert!(
first.contains("Page 1 of 2"),
"the pager states where it is"
);
let second = client
.get("/admin/media?page=2")
.header("cookie", &cookie)
.send()
.await;
second.assert_ok();
let second = second.text();
assert!(
second.contains("File 049") && second.contains("File 050"),
"the tail is reachable"
);
assert!(
!second.contains("File 001"),
"page two must not repeat page one"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_guests_identity_fields_are_capped() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Caps", "Body.", "publish").await;
sign_out(&client);
let long_name = "n".repeat(81);
let long_email = format!("{}@example.com", "e".repeat(250));
let long_url = format!("https://example.com/{}", "u".repeat(200));
for (label, fields) in [
(
"name",
vec![
("body", "Hello"),
("author_name", long_name.as_str()),
("author_email", "guest@example.com"),
],
),
(
"email",
vec![
("body", "Hello"),
("author_name", "Guest"),
("author_email", long_email.as_str()),
],
),
(
"url",
vec![
("body", "Hello"),
("author_name", "Guest"),
("author_email", "guest@example.com"),
("author_url", long_url.as_str()),
],
),
] {
let refused = client
.post(&format!("/comments/{post_id}"))
.form(&form(&fields))
.send()
.await;
assert_ne!(
refused.status,
303,
"an oversized {label} must be refused: {}",
refused.text()
);
}
sign_out(&client);
let reader = register(&client, "reader").await;
let long_display = "D".repeat(120);
client
.post("/admin/users/2")
.header("cookie", &cookie)
.form(&form(&[
("role", "subscriber"),
("email", "reader@example.com"),
("display_name", long_display.as_str()),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &reader)
.form(&form(&[("body", "From the account.")]))
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn reparenting_is_bounded_by_the_deepest_descendant() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let page = async |title: &str, parent: Option<&str>| -> String {
let mut fields = vec![
("title", title),
("slug", ""),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
];
if let Some(parent) = parent {
fields.push(("parent_id", parent));
}
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(created.status, 303, "creating {title}: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let mut chain = Vec::new();
for level in 1..=6 {
let parent = chain.last().cloned();
chain.push(page(&format!("P{level}"), parent.as_deref()).await);
}
let a = page("A", None).await;
let b = page("B", Some(&a)).await;
let _c = page("C", Some(&b)).await;
let refused = client
.post(&format!("/admin/content/page/{a}"))
.header("cookie", &cookie)
.form(
&edit_form(
&a,
&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("parent_id", chain[5].as_str()),
],
)
.await,
)
.send()
.await;
assert_eq!(
refused.status,
422,
"the subtree's own height has to count: {}",
refused.text()
);
sign_out(&client);
client
.get("/a/b/c")
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
let leaf = page("Leaf", None).await;
client
.post(&format!("/admin/content/page/{leaf}"))
.header("cookie", &cookie)
.form(
&edit_form(
&leaf,
&[
("title", "Leaf"),
("slug", "leaf"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("parent_id", chain[5].as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_failed_import_leaves_no_half_created_taxonomy() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let db = TestDb::shared().await;
try_execute(
db,
"CREATE OR REPLACE FUNCTION refuse_boom() RETURNS trigger AS $$
BEGIN
IF NEW.name = 'Boom' THEN RAISE EXCEPTION 'boom'; END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql",
)
.await
.expect("create the trigger function");
try_execute(db, "DROP TRIGGER IF EXISTS refuse_boom ON terms")
.await
.expect("clear any previous trigger");
try_execute(
db,
"CREATE TRIGGER refuse_boom BEFORE INSERT ON terms
FOR EACH ROW EXECUTE FUNCTION refuse_boom()",
)
.await
.expect("install the trigger");
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"attachments": [],
"posts": [],
"terms": [
{"taxonomy": "category", "name": "Guides", "slug": "guides", "description": ""},
{"taxonomy": "category", "name": "Boom", "slug": "boom", "description": ""}
]
})
.to_string();
let failed = import_export(&client, &cookie, payload.as_str()).await;
assert_ne!(failed.status, 303, "the import must not report success");
let categories = client
.get("/admin/terms/category")
.header("cookie", &cookie)
.send()
.await;
categories.assert_ok();
assert!(
!categories.text().contains("Guides"),
"a failed term pass must roll its creations back, or a retry reads \
them as local content and never finishes the ancestry"
);
try_execute(db, "DROP TRIGGER refuse_boom ON terms")
.await
.expect("remove the trigger");
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"attachments": [],
"posts": [],
"terms": [
{"taxonomy": "category", "name": "Beginner", "slug": "beginner",
"description": "", "parent": "guides"},
{"taxonomy": "category", "name": "Guides", "slug": "guides", "description": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok();
client
.get("/admin/terms/category")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("Guides — ");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_content_admin_list_is_paginated() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO posts (post_type, title, slug, excerpt, body, status, author_id,
password, comment_status, menu_order, updated_at)
SELECT 'post',
'Item ' || lpad(g::text, 2, '0'),
'item-' || lpad(g::text, 2, '0'),
'', 'Body.', 'publish', 1, '', 'open', 0,
NOW() - (g || ' minutes')::interval
FROM generate_series(1, 60) AS g",
)
.await
.expect("seed the content list");
let first = client
.get("/admin/content/post")
.header("cookie", &cookie)
.send()
.await;
first.assert_ok();
let first = first.text();
assert!(first.contains("Item 01"), "the newest row leads page one");
assert!(first.contains("Item 50"), "page one holds a full page");
assert!(
!first.contains("Item 51"),
"page one must stop at the page size rather than render the whole table"
);
assert!(first.contains("Page 1 of 2"));
let second = client
.get("/admin/content/post?page=2")
.header("cookie", &cookie)
.send()
.await;
second.assert_ok();
let second = second.text();
assert!(second.contains("Item 60"), "the tail is reachable");
assert!(
!second.contains("Item 01"),
"page two must not repeat page one"
);
let drafts = client
.get("/admin/content/post?status=draft")
.header("cookie", &cookie)
.send()
.await;
drafts.assert_ok();
assert!(!drafts.text().contains("Item 01"));
let searched = client
.get("/admin/content/post?s=Item")
.header("cookie", &cookie)
.send()
.await;
searched.assert_ok();
let searched = searched.text();
assert!(searched.contains("Item 01"));
assert!(
!searched.contains("Item 51"),
"a search is bounded too, not just the unfiltered list"
);
sign_out(&client);
let contributor = register(&client, "contributor").await;
client
.post("/admin/users/2")
.header("cookie", &cookie)
.form(&form(&[
("role", "contributor"),
("email", "contributor@example.com"),
("display_name", "Contributor"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
let theirs = client
.get("/admin/content/post")
.header("cookie", &contributor)
.send()
.await;
theirs.assert_ok();
assert!(
!theirs.text().contains("Item 01"),
"a Contributor must not see another account's content"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_featured_media_picker_is_bounded_and_keeps_its_selection() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Illustrated", "Body.", "draft").await;
try_execute(
TestDb::shared().await,
"INSERT INTO attachments (title, slug, mime_type, byte_size, created_at)
SELECT 'Shot ' || lpad(g::text, 3, '0'),
'shot-' || lpad(g::text, 3, '0'),
'image/png',
1024,
NOW() - (g || ' minutes')::interval
FROM generate_series(1, 150) AS g",
)
.await
.expect("seed the library");
let editor = client
.get(&format!("/admin/content/post/{post_id}"))
.header("cookie", &cookie)
.send()
.await;
editor.assert_ok();
let editor = editor.text();
assert!(
editor.contains("Shot 001"),
"the newest uploads are offered"
);
assert!(
!editor.contains("Shot 150"),
"the picker must not render the whole library"
);
assert!(
editor.contains("most recent uploads"),
"the editor says it is showing a window, rather than implying the \
library is this small"
);
let oldest: String = "Shot 150".to_owned();
try_execute(
TestDb::shared().await,
&format!(
"UPDATE posts SET featured_media_id =
(SELECT id FROM attachments WHERE title = '{oldest}')
WHERE id = {post_id}"
),
)
.await
.expect("select the oldest attachment");
let editor = client
.get(&format!("/admin/content/post/{post_id}"))
.header("cookie", &cookie)
.send()
.await;
editor.assert_ok();
assert!(
editor.text().contains(&oldest),
"a selection older than the bound must still be in the select, or \
saving the form unchanged silently clears it"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_admin_menu_lists_every_registered_taxonomy() {
{{crate_name}}::content_types::register_taxonomy({{crate_name}}::content_types::Taxonomy {
slug: "aisle",
singular: "Aisle",
plural: "Aisles",
hierarchical: true,
post_types: &["page"],
rewrite_base: "aisle",
})
.expect("aisle registers cleanly");
let client = db_client().await;
let cookie = register(&client, "owner").await;
let dashboard = client.get("/admin").header("cookie", &cookie).send().await;
dashboard.assert_ok();
let dashboard = dashboard.text();
assert!(
dashboard.contains("/admin/terms/aisle"),
"a registered taxonomy must be reachable from the menu"
);
assert!(dashboard.contains("Aisles"), "under its own plural name");
assert!(dashboard.contains("/admin/terms/category"));
assert!(dashboard.contains("/admin/terms/post_tag"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn scheduling_is_validated_against_the_locked_row() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Someday", "Body.", "draft").await;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let refused = {{crate_name}}::content::transition_status(&mut conn, id, "future", None, None).await;
assert!(
refused.is_err(),
"a schedule with no date must be refused under the lock"
);
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET published_at = NOW() - interval '1 day' WHERE id = {id}"),
)
.await
.expect("rewind the date");
let refused = {{crate_name}}::content::transition_status(&mut conn, id, "future", None, None).await;
assert!(
refused.is_err(),
"a schedule with a past date must be refused under the lock"
);
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET published_at = NOW() + interval '1 day' WHERE id = {id}"),
)
.await
.expect("set a future date");
let scheduled = {{crate_name}}::content::transition_status(&mut conn, id, "future", None, None)
.await
.expect("a real future date schedules");
assert_eq!(scheduled.status, "future");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_users_admin_list_is_paginated() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO users (username, email, password_hash, display_name, role, bio, website)
SELECT 'user-' || lpad(g::text, 2, '0'),
'user-' || lpad(g::text, 2, '0') || '@example.com',
'x', '', 'subscriber', '', ''
FROM generate_series(1, 60) AS g",
)
.await
.expect("seed the accounts");
let first = client
.get("/admin/users")
.header("cookie", &cookie)
.send()
.await;
first.assert_ok();
let first = first.text();
assert!(
first.contains("user-01"),
"the first account leads page one"
);
assert!(first.contains("user-49"), "page one holds a full page");
assert!(
!first.contains("user-50"),
"page one must stop at the page size rather than render every account"
);
assert!(first.contains("Page 1 of 2"));
let second = client
.get("/admin/users?page=2")
.header("cookie", &cookie)
.send()
.await;
second.assert_ok();
let second = second.text();
assert!(
second.contains("user-50") && second.contains("user-60"),
"the tail is reachable"
);
assert!(
!second.contains("user-01"),
"page two must not repeat page one"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_scheduled_date_is_read_in_the_sites_timezone() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("timezone", "America/Los_Angeles")]))
.send()
.await
.assert_status(303);
let local = (chrono::Utc::now() + chrono::Duration::days(30))
.naive_utc()
.format("%Y-%m-%dT09:00")
.to_string();
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Later"),
("slug", "later"),
("excerpt", ""),
("body", "Body."),
("status", "future"),
("password", ""),
("publish_at", local.as_str()),
]))
.send()
.await;
assert_eq!(created.status, 303, "body: {}", created.text());
let id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
let stored: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let when: Option<chrono::NaiveDateTime> = {{crate_name}}::schema::posts::table
.find(id.parse::<i64>().expect("id"))
.select({{crate_name}}::schema::posts::published_at)
.first(&mut conn)
.await
.expect("the scheduled post");
when.expect("a scheduled post has a date")
.format("%H:%M")
.to_string()
};
assert!(
stored == "16:00" || stored == "17:00",
"09:00 Pacific must be stored as the UTC instant it names, not as 09:00 UTC: {stored}"
);
let editor = client
.get(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.send()
.await;
editor.assert_ok();
assert!(
editor.text().contains(&local),
"the editor must redisplay the wall clock the author typed"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn scheduling_a_post_with_no_publish_date_redisplays_the_editor() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let resp = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "A future post"),
("slug", ""),
("excerpt", ""),
("body", "A body worth keeping."),
("status", "future"),
("password", ""),
("publish_at", ""),
]))
.send()
.await;
resp.assert_status(422);
let body = resp.text();
assert!(
body.contains("Pick a publish date for a scheduled post"),
"the field-specific message must be shown: {body}"
);
assert!(
body.contains("A future post") && body.contains("A body worth keeping."),
"the author's title and body must round-trip rather than be lost: {body}"
);
assert!(
body.contains(r#"aria-describedby="publish_at-error""#),
"the publish-date field must be wired to its error for assistive tech: {body}"
);
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let count: i64 = {{crate_name}}::schema::posts::table
.count()
.get_result(&mut conn)
.await
.expect("count");
assert_eq!(count, 0, "a rejected submission must not create a row");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn scheduling_a_post_with_a_past_publish_date_redisplays_the_editor() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let past = (chrono::Utc::now() - chrono::Duration::days(1))
.naive_utc()
.format("%Y-%m-%dT%H:%M")
.to_string();
let resp = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Already due"),
("slug", ""),
("excerpt", ""),
("body", "Still a draft."),
("status", "future"),
("password", ""),
("publish_at", past.as_str()),
]))
.send()
.await;
resp.assert_status(422);
let body = resp.text();
assert!(
body.contains("A scheduled post needs a publish date in the future"),
"the field-specific message must be shown: {body}"
);
assert!(
body.contains(&past),
"the exact wall clock the author typed must round-trip, not a reformatted or blanked \
value: {body}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn rescheduling_an_edit_with_no_publish_date_redisplays_the_editor() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Once live"),
("slug", "once-live"),
("excerpt", ""),
("body", "Original body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
created.assert_status(303);
let id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
let resp = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Once live, edited"),
("slug", "once-live"),
("excerpt", ""),
("body", "Edited body worth keeping."),
("status", "future"),
("password", ""),
],
)
.await,
)
.send()
.await;
resp.assert_status(422);
let body = resp.text();
assert!(
body.contains("Pick a publish date for a scheduled post"),
"the field-specific message must be shown: {body}"
);
assert!(
body.contains("Once live, edited") && body.contains("Edited body worth keeping."),
"the just-typed edit must round-trip, not the previously-saved content: {body}"
);
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let stored_title: String = {{crate_name}}::schema::posts::table
.find(id.parse::<i64>().expect("id"))
.select({{crate_name}}::schema::posts::title)
.first(&mut conn)
.await
.expect("the post");
assert_eq!(
stored_title, "Once live",
"a rejected submission must not write the edit"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn validation_redisplay_keeps_the_submitted_stale_lock_version() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Editor A's starting point"),
("slug", "race"),
("excerpt", ""),
("body", "Original body."),
("status", "draft"),
("password", ""),
]))
.send()
.await;
created.assert_status(303);
let id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let stale_version: i32 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id.parse::<i64>().expect("id"))
.select({{crate_name}}::schema::posts::lock_version)
.first(&mut conn)
.await
.expect("the post")
};
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Editor B's save"),
("slug", "race"),
("excerpt", ""),
("body", "Editor B's body."),
("status", "draft"),
("password", ""),
],
)
.await,
)
.send()
.await
.assert_status(303);
let resp = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&form(&[
("title", "Editor A's edit"),
("slug", "race"),
("excerpt", ""),
("body", "Editor A's body."),
("status", "future"),
("password", ""),
("lock_version", &stale_version.to_string()),
]))
.send()
.await;
resp.assert_status(422);
let body = resp.text();
assert!(
body.contains(&format!(r#"name="lock_version" value="{stale_version}""#)),
"the redisplay must stamp back the version A actually submitted, not the row's current \
(already-advanced) version: {body}"
);
let future = (chrono::Utc::now() + chrono::Duration::days(1))
.naive_utc()
.format("%Y-%m-%dT%H:%M")
.to_string();
let retry = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&form(&[
("title", "Editor A's edit"),
("slug", "race"),
("excerpt", ""),
("body", "Editor A's body."),
("status", "future"),
("password", ""),
("lock_version", &stale_version.to_string()),
("publish_at", future.as_str()),
]))
.send()
.await;
retry.assert_status(409);
let stored_title: String = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id.parse::<i64>().expect("id"))
.select({{crate_name}}::schema::posts::title)
.first(&mut conn)
.await
.expect("the post")
};
assert_eq!(
stored_title, "Editor B's save",
"editor A's stale retry must not overwrite editor B's save"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_completed_import_is_left_alone_on_a_re_import() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"attachments": [],
"posts": [
{
"post_type": "post", "title": "Restored", "slug": "restored",
"excerpt": "", "body": "Imported body.", "status": "publish",
"password": "", "comment_status": "open",
"author": "owner", "terms": [], "comments": [],
"published_at": null, "parent": null, "sticky": false, "menu_order": 0
}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
let id: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("restored"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("the imported post")
};
client
.post(&format!("/admin/content/post/{id}/status?to=draft"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("already present");
let status: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id)
.select({{crate_name}}::schema::posts::status)
.first(&mut conn)
.await
.expect("the imported post")
};
assert_eq!(
status, "draft",
"a finished import must not republish content an editor unpublished"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_parent_picker_is_bounded_and_keeps_its_selection() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO posts (post_type, title, slug, excerpt, body, status, author_id,
password, comment_status, menu_order, updated_at)
SELECT 'page',
'Page ' || lpad(g::text, 3, '0'),
'page-' || lpad(g::text, 3, '0'),
'', 'Body.', 'publish', 1, '', 'closed', 0,
NOW() - (g || ' minutes')::interval
FROM generate_series(1, 150) AS g",
)
.await
.expect("seed the pages");
let editor = client
.get("/admin/content/page/new")
.header("cookie", &cookie)
.send()
.await;
editor.assert_ok();
let editor = editor.text();
assert!(editor.contains("Page 001"), "recent pages are offered");
assert!(
!editor.contains("Page 150"),
"the picker must not render every page of the type"
);
assert!(editor.contains("most recently edited"));
try_execute(
TestDb::shared().await,
"INSERT INTO posts (post_type, title, slug, excerpt, body, status, author_id,
password, comment_status, menu_order, parent_id, updated_at)
SELECT 'page', 'Child', 'child', '', 'Body.', 'publish', 1, '', 'closed', 0,
(SELECT id FROM posts WHERE slug = 'page-150'), NOW()",
)
.await
.expect("seed the child");
let child_id: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("child"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("the child page")
};
let editor = client
.get(&format!("/admin/content/page/{child_id}"))
.header("cookie", &cookie)
.send()
.await;
editor.assert_ok();
assert!(
editor.text().contains("Page 150"),
"a parent older than the bound must still be in the select"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_taxonomy_admin_screen_is_paginated() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO terms (taxonomy, name, slug, description, post_count)
SELECT 'category',
'cat-' || lpad(g::text, 3, '0'),
'cat-' || lpad(g::text, 3, '0'),
'', 0
FROM generate_series(1, 120) AS g",
)
.await
.expect("seed the terms");
let first = client
.get("/admin/terms/category")
.header("cookie", &cookie)
.send()
.await;
first.assert_ok();
let first = first.text();
assert!(first.contains("cat-001"), "the first term leads page one");
assert!(first.contains("cat-050"), "page one holds a full page");
assert_eq!(
first.matches("/delete\"").count(),
50,
"the list must stop at the page size rather than render the taxonomy"
);
assert!(first.contains("Page 1 of 3"));
assert!(
!first.contains("cat-101"),
"the parent selector must not render every term either"
);
assert!(first.contains("Showing the first"));
let third = client
.get("/admin/terms/category?page=3")
.header("cookie", &cookie)
.send()
.await;
third.assert_ok();
let third = third.text();
assert!(third.contains("cat-120"), "the tail is reachable");
assert_eq!(
third.matches("/delete\"").count(),
20,
"the last page holds the remainder, not a repeat of the first"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_dated_permalink_and_its_archive_use_the_site_timezone() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[
("timezone", "America/Los_Angeles"),
("permalink_structure", "day_and_name"),
]))
.send()
.await
.assert_status(303);
create_post(&client, &cookie, "Late", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
"UPDATE posts SET published_at = '2026-09-10 06:30:00' WHERE slug = 'late'",
)
.await
.expect("straddle the date boundary");
sign_out(&client);
let listed = client
.get("/api/v1/posts")
.send()
.await
.assert_ok()
.json::<serde_json::Value>();
let url = listed.as_array().expect("array")[0]["url"]
.as_str()
.expect("url")
.to_owned();
assert!(
url.starts_with("/2026/09/09/"),
"the URL must name the local day, not the UTC one: {url}"
);
client.get(&url).send().await.assert_ok();
client
.get("/2026/09/09")
.send()
.await
.assert_ok()
.assert_body_contains("Late");
let wrong_day = client.get("/2026/09/10").send().await;
wrong_day.assert_ok();
assert!(
!wrong_day.text().contains("Late"),
"the post must not also appear in the following day's archive"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_mistyped_timezone_is_refused_rather_than_resetting_the_site() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("timezone", "America/Los_Angeles")]))
.send()
.await
.assert_status(303);
let refused = client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("timezone", "Amerca/Los_Angeles")]))
.send()
.await;
assert_eq!(
refused.status,
422,
"a name the site cannot resolve must be refused: {}",
refused.text()
);
let screen = client
.get("/admin/settings")
.header("cookie", &cookie)
.send()
.await;
screen.assert_ok();
assert!(
screen.text().contains("America/Los_Angeles"),
"a refused submission must leave the previous zone alone"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_editor_taxonomy_control_is_bounded_and_keeps_its_selection() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Filed", "Body.", "draft").await;
try_execute(
TestDb::shared().await,
"INSERT INTO terms (taxonomy, name, slug, description, post_count)
SELECT 'category',
'cat-' || lpad(g::text, 3, '0'),
'cat-' || lpad(g::text, 3, '0'),
'', 0
FROM generate_series(1, 150) AS g",
)
.await
.expect("seed the terms");
let editor = client
.get(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.send()
.await;
editor.assert_ok();
let editor = editor.text();
assert!(editor.contains("cat-001"), "the first terms are offered");
assert!(
!editor.contains("cat-150"),
"the control must not render the whole taxonomy"
);
assert!(editor.contains("Showing the first"));
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO post_terms (post_id, term_id)
SELECT {id}, id FROM terms WHERE slug = 'cat-150'"
),
)
.await
.expect("file the post");
let editor = client
.get(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.send()
.await;
editor.assert_ok();
assert!(
editor.text().contains("cat-150"),
"a term the post carries must be in the form, or saving unfiles it"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_authors_endpoint_pages_in_sql() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Owned", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
"INSERT INTO users (username, email, password_hash, display_name, role, bio, website)
SELECT 'writer-' || lpad(g::text, 2, '0'),
'writer-' || lpad(g::text, 2, '0') || '@example.com',
'x', '', 'author', '', ''
FROM generate_series(1, 80) AS g",
)
.await
.expect("seed the accounts");
try_execute(
TestDb::shared().await,
"INSERT INTO posts (post_type, title, slug, excerpt, body, status, author_id,
password, comment_status, menu_order)
SELECT 'post',
'By ' || u.username,
'by-' || u.username,
'', 'Body.', 'publish', u.id, '', 'open', 0
FROM users u
WHERE u.username LIKE 'writer-%'
AND substring(u.username from 8)::int <= 60",
)
.await
.expect("seed the posts");
sign_out(&client);
let page: serde_json::Value = client
.get("/api/v1/authors?per_page=5")
.send()
.await
.assert_ok()
.json();
let rows = page.as_array().expect("array");
assert_eq!(rows.len(), 5, "the bound is the request's, not the site's");
let names: Vec<&str> = rows
.iter()
.map(|row| row["username"].as_str().expect("username"))
.collect();
assert_eq!(
names,
vec!["owner", "writer-01", "writer-02", "writer-03", "writer-04"],
"the distinct, the order and the bound all have to survive the rewrite"
);
let later: serde_json::Value = client
.get("/api/v1/authors?per_page=5&page=2")
.send()
.await
.assert_ok()
.json();
let names: Vec<&str> = later
.as_array()
.expect("array")
.iter()
.map(|row| row["username"].as_str().expect("username"))
.collect();
assert_eq!(
names,
vec![
"writer-05",
"writer-06",
"writer-07",
"writer-08",
"writer-09"
]
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_save_cannot_apply_an_unbounded_number_of_terms() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Filed", "Body.", "draft").await;
try_execute(
TestDb::shared().await,
"INSERT INTO terms (taxonomy, name, slug, description, post_count)
SELECT 'category',
'cat-' || lpad(g::text, 3, '0'),
'cat-' || lpad(g::text, 3, '0'),
'', 0
FROM generate_series(1, 80) AS g",
)
.await
.expect("seed the terms");
let ids: Vec<i64> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::taxonomy.eq("category"))
.order({{crate_name}}::schema::terms::slug.asc())
.select({{crate_name}}::schema::terms::id)
.load(&mut conn)
.await
.expect("the seeded terms")
};
let save = async |count: usize| {
let strings: Vec<String> = ids.iter().take(count).map(i64::to_string).collect();
let mut fields = vec![
("title", "Filed"),
("slug", "filed"),
("excerpt", ""),
("body", "Body."),
("status", "draft"),
("password", ""),
];
for value in &strings {
fields.push(("taxonomies[category]", value.as_str()));
}
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&edit_form(&id, &fields).await)
.send()
.await
};
let refused = save(80).await;
assert_eq!(
refused.status,
422,
"a submission past the cap must be refused: {}",
refused.text()
);
let filed: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::post_terms::table
.filter({{crate_name}}::schema::post_terms::post_id.eq(id))
.count()
.get_result(&mut conn)
.await
.expect("the filings")
};
assert_eq!(filed, 0, "a refused save must file nothing");
assert_eq!(save(50).await.status, 303);
let filed: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::post_terms::table
.filter({{crate_name}}::schema::post_terms::post_id.eq(id))
.count()
.get_result(&mut conn)
.await
.expect("the filings")
};
assert_eq!(filed, 50, "the cap bounds the save, it does not break it");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_reply_cannot_be_approved_under_a_hidden_ancestor() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Thread", "Body.", "publish").await;
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "Root comment.")]))
.send()
.await
.assert_status(303);
let root_id: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::comments::table
.filter({{crate_name}}::schema::comments::body.eq("Root comment."))
.select({{crate_name}}::schema::comments::id)
.first(&mut conn)
.await
.expect("the root comment")
};
sign_out(&client);
client
.post(&format!("/comments/{post_id}"))
.form(&form(&[
("body", "Pending reply."),
("author_name", "Guest"),
("author_email", "guest@example.com"),
("reply_to", root_id.to_string().as_str()),
]))
.send()
.await
.assert_status(303);
let reply_id: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::comments::table
.filter({{crate_name}}::schema::comments::body.eq("Pending reply."))
.select({{crate_name}}::schema::comments::id)
.first(&mut conn)
.await
.expect("the reply")
};
client
.post(&format!("/admin/comments/{root_id}/status?to=spam"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let refused = client
.post(&format!("/admin/comments/{reply_id}/status?to=approved"))
.header("cookie", &cookie)
.send()
.await;
assert_eq!(
refused.status,
422,
"approving under a hidden ancestor must be refused: {}",
refused.text()
);
let count: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(post_id)
.select({{crate_name}}::schema::posts::comment_count)
.first(&mut conn)
.await
.expect("the post")
};
assert_eq!(count, 0, "a comment nobody can see must not be counted");
client
.post(&format!("/admin/comments/{root_id}/status?to=approved"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
client
.post(&format!("/admin/comments/{reply_id}/status?to=approved"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
sign_out(&client);
client
.get("/thread")
.send()
.await
.assert_ok()
.assert_body_contains("Pending reply.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_oversized_title_is_refused_on_the_edit_path() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Modest", "Body.", "draft").await;
let huge = "t".repeat(301);
let refused = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", huge.as_str()),
("slug", "modest"),
("excerpt", ""),
("body", "Body."),
("status", "draft"),
("password", ""),
],
)
.await,
)
.send()
.await;
assert_eq!(
refused.status,
422,
"a title past the model's cap must be refused even on a draft: {}",
refused.text()
);
let ok = "t".repeat(300);
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", ok.as_str()),
("slug", "modest"),
("excerpt", ""),
("body", "Body."),
("status", "draft"),
("password", ""),
],
)
.await,
)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_appearance_category_selector_is_bounded() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Primary"), ("location", "primary")]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
"INSERT INTO terms (taxonomy, name, slug, description, post_count)
SELECT 'category',
'cat-' || lpad(g::text, 3, '0'),
'cat-' || lpad(g::text, 3, '0'),
'', 0
FROM generate_series(1, 250) AS g",
)
.await
.expect("seed the terms");
let screen = client
.get("/admin/appearance")
.header("cookie", &cookie)
.send()
.await;
screen.assert_ok();
let screen = screen.text();
assert!(
screen.contains("cat-001"),
"the first categories are offered"
);
assert!(
!screen.contains("cat-250"),
"the selector must not render every category"
);
assert!(screen.contains("First 200 by name"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn unscheduling_a_post_clears_its_publish_date() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let when = (chrono::Utc::now() + chrono::Duration::days(30))
.naive_utc()
.format("%Y-%m-%dT09:00")
.to_string();
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Later"),
("slug", "later"),
("excerpt", ""),
("body", "Body."),
("status", "future"),
("password", ""),
("publish_at", when.as_str()),
]))
.send()
.await;
assert_eq!(created.status, 303, "body: {}", created.text());
let id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&id,
&[
("title", "Later"),
("slug", "later"),
("excerpt", ""),
("body", "Body."),
("status", "draft"),
("password", ""),
("publish_at", ""),
],
)
.await,
)
.send()
.await
.assert_status(303);
let stored: Option<chrono::NaiveDateTime> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id.parse::<i64>().expect("id"))
.select({{crate_name}}::schema::posts::published_at)
.first(&mut conn)
.await
.expect("the post")
};
assert!(
stored.is_none(),
"an unscheduled post must not keep the due time it never reached: {stored:?}"
);
client
.post(&format!("/admin/content/post/{id}/status?to=publish"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
let stored: chrono::NaiveDateTime = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id.parse::<i64>().expect("id"))
.select({{crate_name}}::schema::posts::published_at)
.first::<Option<chrono::NaiveDateTime>>(&mut conn)
.await
.expect("the post")
.expect("a published post is dated")
};
assert!(
stored <= chrono::Utc::now().naive_utc(),
"a post published now must not be dated in the future: {stored}"
);
let live = create_post(&client, &cookie, "Live", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET published_at = '2020-01-01 00:00:00' WHERE id = {live}"),
)
.await
.expect("backdate");
client
.post(&format!("/admin/content/post/{live}"))
.header("cookie", &cookie)
.form(
&edit_form(
&live,
&[
("title", "Live"),
("slug", "live"),
("excerpt", ""),
("body", "Edited."),
("status", "publish"),
("password", ""),
("publish_at", ""),
],
)
.await,
)
.send()
.await
.assert_status(303);
let kept: Option<chrono::NaiveDateTime> = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(live)
.select({{crate_name}}::schema::posts::published_at)
.first(&mut conn)
.await
.expect("the post")
};
assert!(
kept.is_some_and(|when| when.format("%Y").to_string() == "2020"),
"editing a published post must never reorder the blog index: {kept:?}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_import_publishes_a_schedule_that_has_already_passed() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"attachments": [],
"posts": [
{
"post_type": "post", "title": "Was Scheduled", "slug": "was-scheduled",
"excerpt": "", "body": "Imported body.", "status": "future",
"password": "", "comment_status": "open",
"author": "owner", "terms": [], "comments": [],
"published_at": "2020-01-01T00:00:00", "parent": null,
"sticky": false, "menu_order": 0
}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
let status: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("was-scheduled"))
.select({{crate_name}}::schema::posts::status)
.first(&mut conn)
.await
.expect("the imported post")
};
assert_eq!(status, "publish");
sign_out(&client);
let listed: serde_json::Value = client.get("/api/v1/posts").send().await.assert_ok().json();
assert!(
listed
.as_array()
.expect("array")
.iter()
.any(|p| p["slug"] == "was-scheduled"),
"the restored post has to be readable, not stranded"
);
let future = (chrono::Utc::now() + chrono::Duration::days(30))
.naive_utc()
.format("%Y-%m-%dT%H:%M:%S")
.to_string();
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"attachments": [],
"posts": [
{
"post_type": "post", "title": "Still Scheduled", "slug": "still-scheduled",
"excerpt": "", "body": "Imported body.", "status": "future",
"password": "", "comment_status": "open",
"author": "owner", "terms": [], "comments": [],
"published_at": future, "parent": null,
"sticky": false, "menu_order": 0
}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
let status: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("still-scheduled"))
.select({{crate_name}}::schema::posts::status)
.first(&mut conn)
.await
.expect("the imported post")
};
assert_eq!(
status, "future",
"a schedule that has not elapsed must stay a schedule"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_importer_accepts_an_export_too_large_to_url_encode() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let body = "{}&%<>,;".repeat(1_500_000);
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"attachments": [],
"posts": [
{
"post_type": "post", "title": "Bulky", "slug": "bulky",
"excerpt": "", "body": body, "status": "publish",
"password": "", "comment_status": "open",
"author": "owner", "terms": [], "comments": [],
"published_at": null, "parent": null, "sticky": false, "menu_order": 0
}
]
})
.to_string();
assert!(
payload.len() < 24 * 1024 * 1024,
"the fixture has to fit the importer's own cap: {}",
payload.len()
);
assert!(
form(&[("payload", payload.as_str())]).len() > 32 * 1024 * 1024,
"if this does not exceed the limit, the test is not testing the fix"
);
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
let stored: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("bulky"))
.count()
.get_result(&mut conn)
.await
.expect("the imported post")
};
assert_eq!(stored, 1);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_edit_without_a_version_stamp_is_refused() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let id = create_post(&client, &cookie, "Contested", "First.", "publish").await;
let fields = |version: Option<&str>| {
let mut fields = vec![
("title", "Contested"),
("slug", "contested"),
("excerpt", ""),
("body", "Overwritten."),
("status", "publish"),
("password", ""),
("comment_status", "open"),
];
if let Some(version) = version {
fields.push(("lock_version", version));
}
form(&fields)
};
for (label, version) in [
("missing", None),
("empty", Some("")),
("malformed", Some("not-a-number")),
] {
let refused = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&fields(version))
.send()
.await;
assert_eq!(
refused.status,
422,
"a {label} version stamp must be refused rather than skipping the check: {}",
refused.text()
);
}
sign_out(&client);
client
.get("/contested")
.send()
.await
.assert_ok()
.assert_body_contains("First.");
let current: i32 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id)
.select({{crate_name}}::schema::posts::lock_version)
.first(&mut conn)
.await
.expect("the post")
};
let stale = client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&fields(Some(&(current - 1).to_string())))
.send()
.await;
assert_ne!(stale.status, 303, "a stale save must still be refused");
client
.post(&format!("/admin/content/post/{id}"))
.header("cookie", &cookie)
.form(&fields(Some(¤t.to_string())))
.send()
.await
.assert_status(303);
sign_out(&client);
client
.get("/contested")
.send()
.await
.assert_ok()
.assert_body_contains("Overwritten.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_stale_term_count_does_not_advertise_an_empty_archive() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "Ghosts"),
("slug", ""),
("description", ""),
]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
"UPDATE terms SET post_count = 7 WHERE slug = 'ghosts'",
)
.await
.expect("stale the counter");
sign_out(&client);
let home = client.get("/").send().await;
home.assert_ok();
assert!(
!home.text().contains("Ghosts"),
"the widget must not advertise an archive with nothing in it"
);
let sitemap = client.get("/sitemap.xml").send().await;
sitemap.assert_ok();
assert!(
!sitemap.text().contains("/category/ghosts"),
"the sitemap must not list an archive with nothing in it"
);
let id = create_post(&client, &cookie, "Real", "Body.", "publish").await;
let term: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("ghosts"))
.select({{crate_name}}::schema::terms::id)
.first(&mut conn)
.await
.expect("the term")
};
try_execute(
TestDb::shared().await,
&format!("INSERT INTO post_terms (post_id, term_id) VALUES ({id}, {term})"),
)
.await
.expect("file the post");
sign_out(&client);
client
.get("/sitemap.xml")
.send()
.await
.assert_ok()
.assert_body_contains("/category/ghosts");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_import_ceiling_follows_the_configured_request_limit() {
drop(db_client().await);
let db = TestDb::shared().await;
let mut config = AutumnConfig::default();
config.security.csrf.enabled = false;
config.security.submit_token.enabled = false;
config.security.upload.max_request_size_bytes = 96 * 1024 * 1024;
let client = TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.build();
let cookie = register(&client, "owner").await;
let screen = client
.get("/admin/tools")
.header("cookie", &cookie)
.send()
.await;
screen.assert_ok();
let screen = screen.text();
assert!(
screen.contains("Up to 95 MB"),
"the screen must state the configured ceiling, not a constant:\n{screen}"
);
assert!(
screen.contains("max_request_size_bytes"),
"and name the knob that changes it"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn deleting_an_account_will_not_orphan_another_authors_pages() {
let client = db_client().await;
let owner = register(&client, "owner").await;
sign_out(&client);
let author = register(&client, "author").await;
client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "author"),
("email", "author@example.com"),
("display_name", "Author"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
let parent = client
.post("/admin/content/page")
.header("cookie", &author)
.form(&form(&[
("title", "Guides"),
("slug", "guides"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(parent.status, 303, "body: {}", parent.text());
let parent_id = parent
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post("/admin/content/page")
.header("cookie", &owner)
.form(&form(&[
("title", "Install"),
("slug", "install"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("parent_id", parent_id.as_str()),
]))
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/guides/install").send().await.assert_ok();
let refused = client
.post("/admin/users/2/delete")
.header("cookie", &owner)
.send()
.await;
assert_eq!(
refused.status,
422,
"the deletion must be refused rather than re-rooting the child: {}",
refused.text()
);
sign_out(&client);
client
.get("/guides/install")
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
let child: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("install"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("the child page")
};
client
.post(&format!("/admin/content/page/{child}/status?to=trash"))
.header("cookie", &owner)
.send()
.await
.assert_status(303);
client
.post("/admin/users/2/delete")
.header("cookie", &owner)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_slug_cannot_shadow_a_framework_probe_route() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for probe in ["health", "live", "ready", "startup"] {
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", probe),
("slug", probe),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "body: {}", created.text());
let slug: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let id: i64 = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.parse()
.expect("id");
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id)
.select({{crate_name}}::schema::posts::slug)
.first(&mut conn)
.await
.expect("the post")
};
assert_ne!(
slug, probe,
"`{probe}` is mounted by the framework, so a post taking it bare is unreachable"
);
assert!(
slug.starts_with(probe),
"the allocator should suffix rather than rename: {slug}"
);
}
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_publish_sweep_drains_a_backlog_in_batches() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Anchor", "Body.", "draft").await;
try_execute(
TestDb::shared().await,
"INSERT INTO posts (post_type, title, slug, excerpt, body, status, author_id,
password, comment_status, menu_order, published_at)
SELECT 'post',
'Due ' || lpad(g::text, 3, '0'),
'due-' || lpad(g::text, 3, '0'),
'', 'Body.', 'future', 1, '', 'open', 0,
NOW() - (g || ' minutes')::interval
FROM generate_series(1, 150) AS g",
)
.await
.expect("seed the backlog");
let due_count = async || -> i64 {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::status.eq("future"))
.count()
.get_result(&mut conn)
.await
.expect("the backlog")
};
assert_eq!(due_count().await, 150);
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let batch = {{crate_name}}::content::due_scheduled_posts(&mut conn, {{crate_name}}::tasks::PUBLISH_BATCH)
.await
.expect("a batch");
assert_eq!(
batch.len(),
100,
"a sweep must claim a bounded batch, not the whole backlog"
);
assert_eq!(batch[0].slug, "due-150");
assert_eq!(batch[99].slug, "due-051");
for post in &batch {
let status = post
.transition_status_to("publish")
.expect("a due post publishes");
{{crate_name}}::content::publish_due_post(&mut conn, post.id, post.published_at, &status)
.await
.expect("publish");
}
assert_eq!(due_count().await, 50, "the rest wait for the next tick");
let batch = {{crate_name}}::content::due_scheduled_posts(&mut conn, {{crate_name}}::tasks::PUBLISH_BATCH)
.await
.expect("a batch");
assert_eq!(batch.len(), 50, "and the next tick drains them");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_batched_menu_resolves_every_link_kind() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let page = async |title: &str, slug: &str, parent: Option<&str>| -> String {
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
];
if let Some(parent) = parent {
fields.push(("parent_id", parent));
}
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(created.status, 303, "creating {title}: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let about = page("About", "about", None).await;
let team = page("Team", "team", Some(&about)).await;
let nested = page("Alice", "alice", Some(&team)).await;
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[
("name", "News"),
("slug", ""),
("description", ""),
]))
.send()
.await
.assert_status(303);
let term: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("news"))
.select({{crate_name}}::schema::terms::id)
.first(&mut conn)
.await
.expect("the term")
};
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Primary"), ("location", "primary")]))
.send()
.await
.assert_status(303);
let menu: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::menus::table
.select({{crate_name}}::schema::menus::id)
.first(&mut conn)
.await
.expect("the menu")
};
for fields in [
vec![("label", "Deep page"), ("post_id", nested.as_str())],
vec![("label", "News"), ("term_id", &term.to_string())],
vec![("label", "Elsewhere"), ("url", "https://example.com/x")],
] {
let mut all = fields.clone();
all.push(("parent_id", ""));
client
.post(&format!("/admin/appearance/menus/{menu}/items"))
.header("cookie", &cookie)
.form(&form(&all))
.send()
.await
.assert_status(303);
}
sign_out(&client);
let home = client.get("/").send().await;
home.assert_ok();
let home = home.text();
assert!(
home.contains("/about/team/alice"),
"a page target keeps its full ancestry through the batched walk:\n{home}"
);
assert!(home.contains("/category/news"), "a term target resolves");
assert!(
home.contains("https://example.com/x"),
"a raw URL passes through"
);
}
#[test]
fn a_taxonomy_slug_must_be_one_url_segment() {
let refused = {{crate_name}}::content_types::register_taxonomy({{crate_name}}::content_types::Taxonomy {
slug: "product/type",
singular: "Product type",
plural: "Product types",
hierarchical: true,
post_types: &["post"],
rewrite_base: "product-type",
});
let error = refused.expect_err("a slug with a path separator is not a segment");
assert_eq!(error.field, "slug");
{{crate_name}}::content_types::register_taxonomy({{crate_name}}::content_types::Taxonomy {
slug: "product-type",
singular: "Product type",
plural: "Product types",
hierarchical: true,
post_types: &["post"],
rewrite_base: "product-type",
})
.expect("a single-segment slug registers");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_interrupted_import_leaves_no_unmarked_post() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let db = TestDb::shared().await;
try_execute(
db,
"CREATE OR REPLACE FUNCTION refuse_marker() RETURNS trigger AS $$
BEGIN
IF NEW.meta_key = '_import_source_slug' THEN RAISE EXCEPTION 'boom'; END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql",
)
.await
.expect("create the trigger function");
try_execute(db, "DROP TRIGGER IF EXISTS refuse_marker ON post_meta")
.await
.expect("clear any previous trigger");
try_execute(
db,
"CREATE TRIGGER refuse_marker BEFORE INSERT ON post_meta
FOR EACH ROW EXECUTE FUNCTION refuse_marker()",
)
.await
.expect("install the trigger");
let payload = serde_json::json!({
"version": 3,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"attachments": [],
"posts": [
{
"post_type": "post", "title": "Restored", "slug": "restored",
"excerpt": "", "body": "Imported body.", "status": "publish",
"password": "", "comment_status": "open",
"author": "owner", "terms": [], "comments": [],
"published_at": null, "parent": null, "sticky": false, "menu_order": 0
}
]
})
.to_string();
let failed = import_export(&client, &cookie, payload.as_str()).await;
assert_ne!(failed.status, 200, "the import must not report success");
let rows: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("restored"))
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(
rows, 0,
"an unmarked post is the one state a retry cannot recognise, so the \
insert has to roll back with the marker"
);
try_execute(db, "DROP TRIGGER refuse_marker ON post_meta")
.await
.expect("remove the trigger");
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn deleting_an_author_takes_the_hierarchy_lock() {
let client = db_client().await;
let owner = register(&client, "owner").await;
sign_out(&client);
let author = register(&client, "author").await;
client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "author"),
("email", "author@example.com"),
("display_name", "Author"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
client
.post("/admin/content/page")
.header("cookie", &author)
.form(&form(&[
("title", "Guides"),
("slug", "guides"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await
.assert_status(303);
{
use diesel_async::RunQueryDsl;
let mut holder = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query(format!(
"SELECT pg_advisory_lock({})",
{{crate_name}}::content::PAGE_HIERARCHY_LOCK_KEY
))
.execute(&mut holder)
.await
.expect("take the lock");
let mut worker = TestDb::shared().await.pool().get().await.expect("conn");
let blocked = tokio::time::timeout(
std::time::Duration::from_millis(750),
{{crate_name}}::content::delete_user(&mut worker, 1, 2),
)
.await;
assert!(
blocked.is_err(),
"the deletion must wait on the hierarchy lock, not race it"
);
diesel::sql_query(format!(
"SELECT pg_advisory_unlock({})",
{{crate_name}}::content::PAGE_HIERARCHY_LOCK_KEY
))
.execute(&mut holder)
.await
.expect("release the lock");
}
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::delete_user(&mut conn, 1, 2)
.await
.expect("the deletion proceeds once the lock is free");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_locked_post_renders_no_comment_thread() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Secret"),
("slug", "secret"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", "hunter2"),
("comment_status", "open"),
]))
.send()
.await;
assert_eq!(created.status, 303, "body: {}", created.text());
let id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post(&format!("/unlock/{id}"))
.header("cookie", &cookie)
.form(&form(&[("password", "hunter2")]))
.send()
.await
.assert_status(303);
client
.post(&format!("/comments/{id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "Insider knowledge.")]))
.send()
.await
.assert_status(303);
sign_out(&client);
let locked = client.get("/secret").send().await;
locked.assert_ok();
let locked = locked.text();
assert!(
!locked.contains("Insider knowledge."),
"a locked post must not render its thread"
);
assert!(
!locked.contains("comments-heading"),
"and must not render the thread scaffolding either — the work is what \
this is about, not just the text:\n{locked}"
);
client
.post(&format!("/unlock/{id}"))
.form(&form(&[("password", "hunter2")]))
.send()
.await
.assert_status(303);
client
.get("/secret")
.send()
.await
.assert_ok()
.assert_body_contains("Insider knowledge.");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_thread_by_many_accounts_renders_every_name() {
let client = db_client().await;
let owner = register(&client, "owner").await;
let post_id = create_post(&client, &owner, "Busy", "Body.", "publish").await;
for n in 1..=3 {
sign_out(&client);
let cookie = register(&client, &format!("commenter{n}")).await;
client
.post(&format!("/admin/users/{}", n + 1))
.header("cookie", &owner)
.form(&form(&[
("role", "subscriber"),
("email", &format!("commenter{n}@example.com")),
("display_name", &format!("Person {n}")),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", &format!("Comment {n}."))]))
.send()
.await
.assert_status(303);
}
sign_out(&client);
client
.post(&format!("/comments/{post_id}"))
.form(&form(&[
("body", "Passing through."),
("author_name", "Visitor"),
("author_email", "visitor@example.com"),
]))
.send()
.await
.assert_status(303);
client
.post("/admin/comments/4/status?to=approved")
.header("cookie", &owner)
.send()
.await
.assert_status(303);
sign_out(&client);
let page = client.get("/busy").send().await;
page.assert_ok();
let page = page.text();
for n in 1..=3 {
assert!(
page.contains(&format!("Person {n}")),
"every registered commenter renders under their current public name:\n{page}"
);
}
assert!(
page.contains("Visitor"),
"and a guest under the name they gave"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn pages_under_different_parents_may_share_a_slug() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let page = async |title: &str, slug: &str, parent: Option<&str>| -> String {
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
];
if let Some(parent) = parent {
fields.push(("parent_id", parent));
}
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(created.status, 303, "creating {title}: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let about = page("About", "about", None).await;
let company = page("Company", "company", None).await;
let first = page("Team", "team", Some(&about)).await;
let second = page("Team", "team", Some(&company)).await;
let slug_of = async |id: &str| -> String {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.find(id.parse::<i64>().expect("id"))
.select({{crate_name}}::schema::posts::slug)
.first(&mut conn)
.await
.expect("the page")
};
assert_eq!(slug_of(&first).await, "team");
assert_eq!(
slug_of(&second).await,
"team",
"a sibling of a different parent is not a collision"
);
sign_out(&client);
client
.get("/about/team")
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
client
.get("/company/team")
.send()
.await
.assert_ok()
.assert_body_contains("Body.");
let third = page("Team", "team", Some(&about)).await;
assert_eq!(
slug_of(&third).await,
"team-2",
"two children of the same parent would mint the same URL"
);
create_post(&client, &cookie, "Careers", "Body.", "publish").await;
let bare = page("Careers", "careers", None).await;
assert_eq!(
slug_of(&bare).await,
"careers-2",
"a top-level page and a post both mint /careers"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_backup_with_duplicate_page_slugs_restores_every_page() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let page = async |title: &str, slug: &str, parent: Option<&str>| -> String {
let body = format!("Body of {slug}.");
let mut fields = vec![
("title", title),
("slug", slug),
("excerpt", ""),
("body", body.as_str()),
("status", "publish"),
("password", ""),
];
if let Some(parent) = parent {
fields.push(("parent_id", parent));
}
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(created.status, 303, "creating {title}: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let about = page("About", "about", None).await;
let company = page("Company", "company", None).await;
page("Team", "team", Some(&about)).await;
page("Team", "team", Some(&company)).await;
let exported = client
.get("/admin/tools/export")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.text();
let payload: serde_json::Value = serde_json::from_str(&exported).expect("valid export JSON");
let paths: Vec<&str> = payload["posts"]
.as_array()
.expect("array")
.iter()
.filter_map(|post| post["path"].as_str())
.collect();
assert!(
paths.contains(&"about/team") && paths.contains(&"company/team"),
"the export has to say which page each one is: {paths:?}"
);
let fresh = db_client().await;
let cookie = register(&fresh, "owner").await;
import_export(&fresh, &cookie, exported.as_str())
.await
.assert_ok()
.assert_body_contains("4 imported");
sign_out(&fresh);
fresh
.get("/about/team")
.send()
.await
.assert_ok()
.assert_body_contains("Body of team.");
fresh
.get("/company/team")
.send()
.await
.assert_ok()
.assert_body_contains("Body of team.");
import_export(&fresh, &cookie, exported.as_str())
.await
.assert_ok()
.assert_body_contains("4 already present");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn same_run_import_restores_pathless_pages_nested_under_different_parents() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("4 imported, 0 already present");
sign_out(&client);
client.get("/a").send().await.assert_ok();
client.get("/b").send().await.assert_ok();
client.get("/a/team").send().await.assert_ok();
client.get("/b/team").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn same_run_import_does_not_confuse_a_top_level_page_with_a_nested_namesake() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "C", "slug": "c", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "c", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("3 imported, 0 already present");
sign_out(&client);
client.get("/team").send().await.assert_ok();
client.get("/c").send().await.assert_ok();
client.get("/c/team").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 3 already present");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn cross_run_import_does_not_confuse_an_unrelated_page_with_a_pathless_one() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let first = serde_json::json!({
"version": 5,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
client.get("/a/team").send().await.assert_ok();
let second = serde_json::json!({
"version": 5,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": "",
"path": "team"}
]
})
.to_string();
import_export(&client, &cookie, second.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
sign_out(&client);
client.get("/a/team").send().await.assert_ok();
client.get("/team").send().await.assert_ok();
import_export(&client, &cookie, second.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 1 already present");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_leaves_an_editor_moved_page_alone() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("3 imported, 0 already present");
client.get("/a/p").send().await.assert_ok();
let (p_id, b_id): (i64, i64) = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let p = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("p"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("p");
let b = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("b"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("b");
(p, b)
};
let b_id_str = b_id.to_string();
client
.post(&format!("/admin/content/page/{p_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&p_id,
&[
("title", "P"),
("slug", "p"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", b_id_str.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/b/p").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 3 already present");
sign_out(&client);
client.get("/b/p").send().await.assert_ok();
assert_eq!(
client.get("/a/p").send().await.status,
404,
"the import must not have created a second `p` under `A`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_finds_a_child_under_a_reslugged_parent() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "Search", "slug": "search", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "search", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
sign_out(&client);
client.get("/search-2/team").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 2 already present");
sign_out(&client);
client.get("/search-2/team").send().await.assert_ok();
assert_eq!(
client.get("/team").send().await.status,
404,
"the import must not have created a second, top-level `team`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_recognizes_a_pre_upgrade_bare_marker() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
client.get("/a/team").send().await.assert_ok();
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let team_id: i64 = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("team"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("team");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(team_id))
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("team"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 2 already present");
sign_out(&client);
client.get("/a/team").send().await.assert_ok();
assert_eq!(
client.get("/team").send().await.status,
404,
"the import must not have created a second, top-level `team`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_recognizes_a_child_of_a_moved_ancestor() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
client.get("/a/p").send().await.assert_ok();
let x_id = {
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "X"),
("slug", "x"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating X: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let a_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("a"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("a")
};
client
.post(&format!("/admin/content/page/{a_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&a_id,
&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", x_id.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/x/a/p").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 2 already present");
sign_out(&client);
client.get("/x/a/p").send().await.assert_ok();
assert_eq!(
client.get("/a/p").send().await.status,
404,
"the import must not have created a second `p` under the old `/a`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_recognizes_a_moved_external_parent() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating A: {}", created.text());
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
client.get("/a/p").send().await.assert_ok();
let x_id = {
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "X"),
("slug", "x"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating X: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let a_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("a"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("a")
};
client
.post(&format!("/admin/content/page/{a_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&a_id,
&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", x_id.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/x/a/p").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 1 already present");
sign_out(&client);
client.get("/x/a/p").send().await.assert_ok();
assert_eq!(
client.get("/a/p").send().await.status,
404,
"the import must not have created a second `p` under the old `/a`"
);
assert_eq!(
client.get("/p").send().await.status,
404,
"the import must not have created a second, top-level `p`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_recognizes_a_pre_upgrade_marker_after_the_parent_moved() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating A: {}", created.text());
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
let p_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("p"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("p")
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(p_id))
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("a/p"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
let x_id = {
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "X"),
("slug", "x"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating X: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let a_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("a"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("a")
};
client
.post(&format!("/admin/content/page/{a_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&a_id,
&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", x_id.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/x/a/p").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 1 already present");
sign_out(&client);
client.get("/x/a/p").send().await.assert_ok();
assert_eq!(
client.get("/a/p").send().await.status,
404,
"the import must not have created a second `p` under the old `/a`"
);
assert_eq!(
client.get("/p").send().await.status,
404,
"the import must not have created a second, top-level `p`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_recognizes_a_pre_upgrade_marker_after_the_child_moved() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating A: {}", created.text());
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
let p_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("p"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("p")
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(p_id))
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("a/p"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
let b_id = {
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "B"),
("slug", "b"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating B: {}", created.text());
created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
client
.post(&format!("/admin/content/page/{p_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&p_id,
&[
("title", "P"),
("slug", "p"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", b_id.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/b/p").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 1 already present");
sign_out(&client);
client.get("/b/p").send().await.assert_ok();
assert_eq!(
client.get("/a/p").send().await.status,
404,
"the import must not have created a second `p` under `A`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_a_new_page_is_not_confused_with_an_unrelated_pre_upgrade_namesake() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Other"),
("slug", "other"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating Other: {}", created.text());
let unrelated_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "other", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, unrelated_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
let unrelated_p_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("p"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("p")
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(unrelated_p_id))
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("other/p"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating A: {}", created.text());
let new_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, new_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
sign_out(&client);
client.get("/other/p").send().await.assert_ok();
client.get("/a/p").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_a_new_descendant_of_a_pre_upgrade_legacy_parent_nests_it_correctly() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating A: {}", created.text());
let first_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
let p_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("p"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("p")
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(p_id))
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("a/p"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
let second_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "C", "slug": "c", "status": "publish",
"author": "owner", "parent": "p", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, second_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 1 already present");
sign_out(&client);
client.get("/a/p/c").send().await.assert_ok();
assert_eq!(
client.get("/c").send().await.status,
404,
"the import must not have created a second, top-level `c`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_a_new_page_is_not_confused_with_a_relocated_current_scheme_namesake() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "B"),
("slug", "b"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating B: {}", created.text());
let first_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
client.get("/b/p").send().await.assert_ok();
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating A: {}", created.text());
let (p_id, a_id): (i64, i64) = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let p = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("p"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("p");
let a = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("a"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("a");
(p, a)
};
let a_id_str = a_id.to_string();
client
.post(&format!("/admin/content/page/{p_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&p_id,
&[
("title", "P"),
("slug", "p"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", a_id_str.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/a/p").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
let new_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P2", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, new_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
sign_out(&client);
let new_under_a: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::title.eq("P2"))
.filter({{crate_name}}::schema::posts::parent_id.eq(a_id))
.count()
.get_result(&mut conn)
.await
.expect("count")
};
assert_eq!(new_under_a, 1, "the new `P2` must exist under `A`");
client.get("/a/p").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_a_new_page_is_not_confused_with_a_relocated_legacy_namesake() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "B"),
("slug", "b"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating B: {}", created.text());
let first_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P", "slug": "p", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
let p_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("p"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("p")
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(p_id))
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("b/p"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
let created = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "A"),
("slug", "a"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "creating A: {}", created.text());
let a_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("a"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("a")
};
let a_id_str = a_id.to_string();
client
.post(&format!("/admin/content/page/{p_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&p_id,
&[
("title", "P"),
("slug", "p"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", a_id_str.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/a/p").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
let new_payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "P2", "slug": "p", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, new_payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
sign_out(&client);
let new_under_a: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::title.eq("P2"))
.filter({{crate_name}}::schema::posts::parent_id.eq(a_id))
.count()
.get_result(&mut conn)
.await
.expect("count")
};
assert_eq!(new_under_a, 1, "the new `P2` must exist under `A`");
client.get("/a/p").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_recognizes_a_three_level_pathless_chain() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "C", "slug": "c", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("3 imported, 0 already present");
client.get("/a/b/c").send().await.assert_ok();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 3 already present");
sign_out(&client);
client.get("/a/b/c").send().await.assert_ok();
assert_eq!(
client.get("/c").send().await.status,
404,
"the import must not have created a second, top-level `c`"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_a_new_descendant_of_a_settled_tree_nests_it_correctly() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let first = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
client.get("/a/b").send().await.assert_ok();
let second = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "C", "slug": "c", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, second.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 2 already present");
sign_out(&client);
client.get("/a/b/c").send().await.assert_ok();
assert_eq!(
client.get("/c").send().await.status,
404,
"the new page must nest under its real parent, not land at the top level"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_bare_parent_reference_resolves_a_path_carrying_entry() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 5,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": "",
"path": "a/b"},
{"post_type": "page", "title": "C", "slug": "c", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("3 imported, 0 already present");
sign_out(&client);
client.get("/a/b/c").send().await.assert_ok();
assert_eq!(
client.get("/c").send().await.status,
404,
"C must nest under B's real path, not land at the top level"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_recognizes_both_sides_of_a_pre_upgrade_bare_collision() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("4 imported, 0 already present");
client.get("/a/team").send().await.assert_ok();
client.get("/b/team").send().await.assert_ok();
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let team_ids: Vec<i64> = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("team"))
.select({{crate_name}}::schema::posts::id)
.load(&mut conn)
.await
.expect("both team rows");
assert_eq!(team_ids.len(), 2, "two Team rows");
for id in team_ids {
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(id))
.filter(
{{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY),
),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("team"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
}
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 4 already present");
sign_out(&client);
client.get("/a/team").send().await.assert_ok();
client.get("/b/team").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn re_importing_a_backup_prefers_the_unfinished_candidate_with_the_matching_parent() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Notes", "slug": "notes", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Notes", "slug": "notes", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("4 imported, 0 already present");
client.get("/a/notes").send().await.assert_ok();
client.get("/b/notes").send().await.assert_ok();
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let notes_ids: Vec<i64> = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("notes"))
.select({{crate_name}}::schema::posts::id)
.load(&mut conn)
.await
.expect("both notes rows");
assert_eq!(notes_ids.len(), 2, "two Notes rows");
for id in ¬es_ids {
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(id))
.filter(
{{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY),
),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("notes"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
diesel::delete(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(id))
.filter(
{{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_COMPLETED_KEY),
),
)
.execute(&mut conn)
.await
.expect("strip the completion marker");
}
}
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 4 already present");
sign_out(&client);
client.get("/a/notes").send().await.assert_ok();
client.get("/b/notes").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_new_top_level_page_is_not_paired_with_an_unfinished_nested_marker() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let first = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
client.get("/a/team").send().await.assert_ok();
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter(
{{crate_name}}::schema::post_meta::post_id.eq_any(
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("team"))
.select({{crate_name}}::schema::posts::id),
),
)
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("team"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
diesel::delete(
{{crate_name}}::schema::post_meta::table
.filter(
{{crate_name}}::schema::post_meta::post_id.eq_any(
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("team"))
.select({{crate_name}}::schema::posts::id),
),
)
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_COMPLETED_KEY)),
)
.execute(&mut conn)
.await
.expect("strip the completion marker");
}
let second = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, second.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
sign_out(&client);
client.get("/team").send().await.assert_ok();
client.get("/a/team").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_path_derived_single_segment_parent_resolves_by_full_identity() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 5,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": "",
"path": "a"},
{"post_type": "page", "title": "Nested B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": "",
"path": "a/b"},
{"post_type": "page", "title": "Decoy C", "slug": "c", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": "",
"path": "a/b/c"},
{"post_type": "page", "title": "Top B", "slug": "b", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": "",
"path": "b"},
{"post_type": "page", "title": "C", "slug": "c", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": "",
"path": "b/c"}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("5 imported, 0 already present");
sign_out(&client);
client.get("/a/b/c").send().await.assert_ok();
client.get("/b/c").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_new_top_level_page_is_not_confused_with_a_pre_upgrade_nested_marker() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let first = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
client.get("/a/team").send().await.assert_ok();
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let team_id: i64 = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("team"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("team");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(team_id))
.filter({{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY)),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq("team"))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
let second = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, second.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 0 already present");
sign_out(&client);
client.get("/a/team").send().await.assert_ok();
client.get("/team").send().await.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn importing_a_new_descendant_of_a_pre_upgrade_settled_tree_nests_it_correctly() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let first = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported, 0 already present");
client.get("/a/b").send().await.assert_ok();
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
for slug in ["a", "b"] {
let id: i64 = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq(slug))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("the row");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(id))
.filter(
{{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY),
),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq(slug))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
}
let second = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "C", "slug": "c", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, second.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported, 2 already present");
sign_out(&client);
client.get("/a/b/c").send().await.assert_ok();
assert_eq!(
client.get("/c").send().await.status,
404,
"the new page must nest under its real parent, not land at the top level"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_pathless_import_and_a_later_explicit_path_import_agree_on_position() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let first = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "X", "slug": "x", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": ""},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, first.as_str())
.await
.assert_ok()
.assert_body_contains("3 imported, 0 already present");
client.get("/a/team").send().await.assert_ok();
let (team_id, x_id): (i64, i64) = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let team = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("team"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("team");
let x = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("x"))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("x");
(team, x)
};
let x_id_str = x_id.to_string();
client
.post(&format!("/admin/content/page/{team_id}"))
.header("cookie", &cookie)
.form(
&edit_form(
&team_id,
&[
("title", "Team"),
("slug", "team"),
("excerpt", ""),
("body", ""),
("status", "publish"),
("password", ""),
("parent_id", x_id_str.as_str()),
],
)
.await,
)
.send()
.await
.assert_status(303);
sign_out(&client);
client.get("/x/team").send().await.assert_ok();
let cookie = sign_in(&client, "owner").await;
let second = serde_json::json!({
"version": 5,
"site_title": "repro",
"exported_at": "2026-02-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": "",
"path": "a"},
{"post_type": "page", "title": "X", "slug": "x", "status": "publish",
"author": "owner", "parent": null, "comment_status": "open", "password": "",
"path": "x"},
{"post_type": "page", "title": "Team", "slug": "team", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": "",
"path": "a/team"}
]
})
.to_string();
import_export(&client, &cookie, second.as_str())
.await
.assert_ok()
.assert_body_contains("0 imported, 3 already present");
sign_out(&client);
client.get("/x/team").send().await.assert_ok();
assert_eq!(
client.get("/a/team").send().await.status,
404,
"the explicit-path re-import must not have created a duplicate at Team's old position"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_cyclic_legacy_parent_reference_with_pre_upgrade_markers_does_not_hang() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported");
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
for slug in ["a", "b"] {
let id: i64 = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq(slug))
.select({{crate_name}}::schema::posts::id)
.first(&mut conn)
.await
.expect("the row");
diesel::update(
{{crate_name}}::schema::post_meta::table
.filter({{crate_name}}::schema::post_meta::post_id.eq(id))
.filter(
{{crate_name}}::schema::post_meta::meta_key.eq({{crate_name}}::content::IMPORT_SOURCE_SLUG_KEY),
),
)
.set({{crate_name}}::schema::post_meta::meta_value.eq(slug))
.execute(&mut conn)
.await
.expect("rewrite the marker");
}
}
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_import_with_a_cyclic_parent_reference_does_not_hang() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "repro",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{"post_type": "page", "title": "A", "slug": "a", "status": "publish",
"author": "owner", "parent": "b", "comment_status": "open", "password": ""},
{"post_type": "page", "title": "B", "slug": "b", "status": "publish",
"author": "owner", "parent": "a", "comment_status": "open", "password": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("2 imported");
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let rows: std::collections::HashMap<String, (i64, Option<i64>)> = {{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq_any(["a", "b"]))
.select((
{{crate_name}}::schema::posts::slug,
{{crate_name}}::schema::posts::id,
{{crate_name}}::schema::posts::parent_id,
))
.load::<(String, i64, Option<i64>)>(&mut conn)
.await
.expect("both rows")
.into_iter()
.map(|(slug, id, parent_id)| (slug, (id, parent_id)))
.collect();
assert_eq!(rows.len(), 2, "both pages were imported: {rows:?}");
let (a_id, a_parent) = rows["a"];
let (b_id, b_parent) = rows["b"];
assert!(
!(a_parent == Some(b_id) && b_parent == Some(a_id)),
"the two pages must not end up parents of each other"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_malformed_email_cannot_register() {
let client = db_client().await;
for address in ["user@", "@example.com", "no-at-sign", "a@b@c.com"] {
let refused = client
.post("/register")
.form(&form(&[
("username", "someone"),
("email", address),
("password", "correct horse battery staple"),
]))
.send()
.await;
assert_ne!(
refused.status,
303,
"`{address}` is not a valid address: {}",
refused.text()
);
}
let accounts: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::users::table
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(accounts, 0, "no malformed address may have been stored");
client
.post("/register")
.form(&form(&[
("username", "someone"),
("email", "someone@example.com"),
("password", "correct horse battery staple"),
]))
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_interrupted_creation_leaves_no_post() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let db = TestDb::shared().await;
try_execute(
db,
"CREATE OR REPLACE FUNCTION refuse_revision() RETURNS trigger AS $$
BEGIN
IF NEW.summary = 'Created' THEN RAISE EXCEPTION 'boom'; END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql",
)
.await
.expect("create the trigger function");
try_execute(db, "DROP TRIGGER IF EXISTS refuse_revision ON revisions")
.await
.expect("clear any previous trigger");
try_execute(
db,
"CREATE TRIGGER refuse_revision BEFORE INSERT ON revisions
FOR EACH ROW EXECUTE FUNCTION refuse_revision()",
)
.await
.expect("install the trigger");
let failed = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Half Made"),
("slug", "half-made"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_ne!(failed.status, 303, "the creation must not report success");
let rows: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("half-made"))
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(
rows, 0,
"the insert has to roll back with the work that completes it"
);
try_execute(db, "DROP TRIGGER refuse_revision ON revisions")
.await
.expect("remove the trigger");
let created = client
.post("/admin/content/post")
.header("cookie", &cookie)
.form(&form(&[
("title", "Half Made"),
("slug", "half-made"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "body: {}", created.text());
let id = created
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.get(&format!("/admin/content/post/{id}/revisions"))
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("Created");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_interrupted_api_creation_leaves_no_post() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let db = TestDb::shared().await;
try_execute(
db,
"CREATE OR REPLACE FUNCTION refuse_private() RETURNS trigger AS $$
BEGIN
IF NEW.status = 'private' THEN RAISE EXCEPTION 'boom'; END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql",
)
.await
.expect("create the trigger function");
try_execute(db, "DROP TRIGGER IF EXISTS refuse_private ON posts")
.await
.expect("clear any previous trigger");
try_execute(
db,
"CREATE TRIGGER refuse_private BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION refuse_private()",
)
.await
.expect("install the trigger");
let refused = client
.post("/api/v1/posts")
.header("cookie", &cookie)
.json(&serde_json::json!({
"title": "Behind Closed Doors",
"body": "Body.",
"status": "private"
}))
.send()
.await;
assert_ne!(
refused.status,
201,
"the creation must not report success: {}",
refused.text()
);
let rows: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(
rows, 0,
"the insert has to roll back with the transition that completes it"
);
try_execute(db, "DROP TRIGGER refuse_private ON posts")
.await
.expect("remove the trigger");
let created = client
.post("/api/v1/posts")
.header("cookie", &cookie)
.json(&serde_json::json!({
"title": "Behind Closed Doors",
"body": "Body.",
"status": "private"
}))
.send()
.await;
assert_eq!(created.status, 201, "body: {}", created.text());
assert_eq!(
created.json::<serde_json::Value>()["slug"],
"behind-closed-doors"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_malformed_email_cannot_be_saved_on_an_existing_account() {
let client = db_client().await;
let owner = register(&client, "owner").await;
sign_out(&client);
register(&client, "editor").await;
let refused = client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "editor"),
("email", "editor@"),
("display_name", "Editor"),
("bio", ""),
("website", ""),
]))
.send()
.await;
assert_eq!(
refused.status,
422,
"the edit path applies the same rule the create path does: {}",
refused.text()
);
let stored: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::users::table
.find(2_i64)
.select({{crate_name}}::schema::users::email)
.first(&mut conn)
.await
.expect("the account")
};
assert_eq!(stored, "editor@example.com", "the address is unchanged");
client
.post("/admin/users/2")
.header("cookie", &owner)
.form(&form(&[
("role", "editor"),
("email", "editor@elsewhere.test"),
("display_name", "Editor"),
("bio", ""),
("website", ""),
]))
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_comment_is_refused_when_the_password_moved_under_the_lock() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Open Then Shut", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!("UPDATE posts SET password = 'hunter2' WHERE id = {post_id}"),
)
.await
.expect("protect the post");
let refused = {{crate_name}}::content::create_comment(
&mut TestDb::shared().await.pool().get().await.expect("conn"),
{{crate_name}}::models::NewComment {
post_id,
parent_id: None,
author_id: None,
author_name: "Guest".to_owned(),
author_email: "guest@example.com".to_owned(),
author_url: String::new(),
author_ip: String::new(),
body: "Slipped through".to_owned(),
status: "approved".to_owned(),
},
"",
)
.await;
assert!(
refused.is_err(),
"the insert must re-check the password, not only the status"
);
{{crate_name}}::content::create_comment(
&mut TestDb::shared().await.pool().get().await.expect("conn"),
{{crate_name}}::models::NewComment {
post_id,
parent_id: None,
author_id: None,
author_name: "Guest".to_owned(),
author_email: "guest@example.com".to_owned(),
author_url: String::new(),
author_ip: String::new(),
body: "Unlocked properly".to_owned(),
status: "approved".to_owned(),
},
"hunter2",
)
.await
.expect("an unlocked commenter may still comment");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_appearance_menu_list_is_bounded() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO menus (name, slug, location)
SELECT 'Menu ' || lpad(g::text, 3, '0'),
'menu-' || lpad(g::text, 3, '0'),
''
FROM generate_series(1, 45) AS g",
)
.await
.expect("seed the menus");
let first = client
.get("/admin/appearance")
.header("cookie", &cookie)
.send()
.await;
first.assert_ok();
let first = first.text();
assert!(first.contains("Menu 001"), "the first menus are shown");
assert!(
!first.contains("Menu 021"),
"the screen must not render every menu"
);
assert!(first.contains("Page 1 of 3"));
let last = client
.get("/admin/appearance?page=3")
.header("cookie", &cookie)
.send()
.await;
last.assert_ok();
let last = last.text();
assert!(last.contains("Menu 045"), "the tail is reachable");
assert!(!last.contains("Menu 001"), "page three is not page one");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_import_cannot_store_an_oversized_term_name() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 4,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"attachments": [],
"posts": [],
"terms": [
{"taxonomy": "category", "name": "x".repeat(201), "slug": "huge", "description": ""}
]
})
.to_string();
let refused = import_export(&client, &cookie, payload.as_str()).await;
assert_ne!(refused.status, 200, "the import must not report success");
let terms: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::terms::table
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(terms, 0, "and must store nothing");
let payload = serde_json::json!({
"version": 4,
"site_title": "Elsewhere",
"exported_at": "2026-01-01T00:00:00Z",
"attachments": [],
"posts": [],
"terms": [
{"taxonomy": "category", "name": "x".repeat(200), "slug": "big", "description": ""}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok();
let terms: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::terms::table
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(terms, 1);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_menu_assigned_to_the_footer_is_rendered() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Legal"), ("location", "footer")]))
.send()
.await
.assert_status(303);
let menu: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::menus::table
.filter({{crate_name}}::schema::menus::location.eq("footer"))
.select({{crate_name}}::schema::menus::id)
.first(&mut conn)
.await
.expect("the menu")
};
client
.post(&format!("/admin/appearance/menus/{menu}/items"))
.header("cookie", &cookie)
.form(&form(&[
("label", "Privacy"),
("url", "/privacy"),
("parent_id", ""),
]))
.send()
.await
.assert_status(303);
sign_out(&client);
let home = client.get("/").send().await;
home.assert_ok();
let home = home.text();
assert!(
home.contains("Privacy") && home.contains("/privacy"),
"a footer menu must reach the page it was assigned to:\n{home}"
);
assert!(
home.contains(r#"aria-label="Footer""#),
"and be labelled as navigation rather than loose links"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_invalid_menu_name_is_refused_and_redisplayed() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for name in [" ", &"x".repeat(201)] {
let resp = client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", name), ("location", "primary")]))
.send()
.await;
resp.assert_status(422);
assert!(
resp.header("location").is_none(),
"a rejected submission must not redirect"
);
resp.assert_body_contains("must be between 1 and 200 characters")
.assert_body_contains(r#"value="primary" selected"#);
}
let count: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::menus::table
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(count, 0, "neither invalid name may be persisted");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_refused_menu_item_or_widget_is_redisplayed_with_its_input() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Main"), ("location", "primary")]))
.send()
.await
.assert_status(303);
let blank = client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[("label", " "), ("url", "/keep-me")]))
.send()
.await;
blank.assert_status(422);
assert!(blank.header("location").is_none());
blank
.assert_body_contains("A menu item needs a label")
.assert_body_contains(r#"role="alert""#)
.assert_body_contains(r#"value="/keep-me""#)
.assert_body_contains("autofocus");
let stale = client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[
("label", "Products"),
("url", "/products"),
("parent_id", "999"),
]))
.send()
.await;
stale.assert_status(422);
stale
.assert_body_contains("can only nest under a top-level item")
.assert_body_contains(r#"value="Products""#)
.assert_body_contains(r#"value="/products""#);
let long_title = client
.post("/admin/appearance/widgets")
.header("cookie", &cookie)
.form(&form(&[
("kind", "text"),
("title", &"t".repeat(201)),
("text", "Keep this blurb."),
("position", "7"),
]))
.send()
.await;
long_title.assert_status(422);
long_title
.assert_body_contains("A widget title must be at most 200 characters")
.assert_body_contains(r#"role="alert""#)
.assert_body_contains("Keep this blurb.")
.assert_body_contains(r#"value="7""#)
.assert_body_contains(r#"value="text" selected"#);
for _ in 0..30 {
client
.post("/admin/appearance/widgets")
.header("cookie", &cookie)
.form(&form(&[("kind", "text"), ("text", "x"), ("position", "0")]))
.send()
.await
.assert_status(303);
}
let full = client
.post("/admin/appearance/widgets")
.header("cookie", &cookie)
.form(&form(&[("kind", "text"), ("text", "One too many.")]))
.send()
.await;
full.assert_status(422);
full.assert_body_contains("Remove one before adding another")
.assert_body_contains("One too many.");
let (items, widgets): (i64, i64) = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
(
{{crate_name}}::schema::menu_items::table
.count()
.get_result(&mut conn)
.await
.expect("item count"),
{{crate_name}}::schema::widgets::table
.count()
.get_result(&mut conn)
.await
.expect("widget count"),
)
};
assert_eq!(items, 0, "no refused item may be stored");
assert_eq!(widgets, 30, "only the widgets that fit were stored");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_refused_item_on_a_later_menu_page_still_shows_its_message() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for n in 1..=21 {
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[
("name", &format!("Menu {n:02}")),
("location", ""),
]))
.send()
.await
.assert_status(303);
}
let refused = client
.post("/admin/appearance/menus/21/items")
.header("cookie", &cookie)
.form(&form(&[("label", " "), ("url", "/kept")]))
.send()
.await;
refused.assert_status(422);
refused
.assert_body_contains("A menu item needs a label")
.assert_body_contains("Menu 21")
.assert_body_contains(r#"value="/kept""#);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_refused_item_keeps_a_target_outside_the_bounded_lists() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Primary"), ("location", "primary")]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
"INSERT INTO terms (taxonomy, name, slug, description, post_count)
SELECT 'category', 'cat-' || lpad(g::text, 3, '0'),
'cat-' || lpad(g::text, 3, '0'), '', 0
FROM generate_series(1, 250) AS g",
)
.await
.expect("seed the terms");
let refused = client
.post("/admin/appearance/menus/1/items")
.header("cookie", &cookie)
.form(&form(&[("label", " "), ("term_id", "250")]))
.send()
.await;
refused.assert_status(422);
refused.assert_body_contains(r#"<option value="250" selected>cat-250"#);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn only_one_menu_can_hold_a_location() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for name in ["First", "Second", "Third"] {
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", name), ("location", "primary")]))
.send()
.await
.assert_status(303);
}
let holders: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::menus::table
.filter({{crate_name}}::schema::menus::location.eq("primary"))
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(holders, 1, "the location has exactly one holder");
let refused = try_execute(
TestDb::shared().await,
"UPDATE menus SET location = 'primary' WHERE name = 'First'",
)
.await;
assert!(
refused.is_err(),
"a second holder must be impossible to store at all"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_failed_ancestor_lookup_does_not_truncate_a_permalink() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let about = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "About"),
("slug", "about"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(about.status, 303, "body: {}", about.text());
let about_id = about
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned();
client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&[
("title", "Team"),
("slug", "team"),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("parent_id", about_id.as_str()),
]))
.send()
.await
.assert_status(303);
sign_out(&client);
let listed: serde_json::Value = client
.get("/api/v1/posts?post_type=page")
.send()
.await
.assert_ok()
.json();
let urls: Vec<&str> = listed
.as_array()
.expect("array")
.iter()
.filter_map(|post| post["url"].as_str())
.collect();
assert!(
urls.contains(&"/about/team"),
"the nested page is published at its full path: {urls:?}"
);
try_execute(
TestDb::shared().await,
"UPDATE posts SET parent_id = NULL WHERE slug = 'team'",
)
.await
.expect("detach");
let listed: serde_json::Value = client
.get("/api/v1/posts?post_type=page")
.send()
.await
.assert_ok()
.json();
let urls: Vec<&str> = listed
.as_array()
.expect("array")
.iter()
.filter_map(|post| post["url"].as_str())
.collect();
assert!(
urls.contains(&"/team"),
"a page with genuinely no parent is `Ok(None)` and keeps its bare path: {urls:?}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_menu_item_pointing_at_hidden_content_is_not_rendered() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Announcement", "Body.", "publish").await;
client
.post("/admin/appearance/menus")
.header("cookie", &cookie)
.form(&form(&[("name", "Primary"), ("location", "primary")]))
.send()
.await
.assert_status(303);
let menu: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::menus::table
.select({{crate_name}}::schema::menus::id)
.first(&mut conn)
.await
.expect("the menu")
};
for fields in [
vec![("label", "News"), ("post_id", &post_id.to_string()[..])],
vec![("label", "Elsewhere"), ("url", "https://example.com/x")],
] {
let mut all = fields.clone();
all.push(("parent_id", ""));
client
.post(&format!("/admin/appearance/menus/{menu}/items"))
.header("cookie", &cookie)
.form(&form(&all))
.send()
.await
.assert_status(303);
}
sign_out(&client);
let home = client.get("/").send().await;
home.assert_ok();
assert!(home.text().contains("News"), "a live target is linked");
client
.post(&format!("/admin/content/post/{post_id}/status?to=draft"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
sign_out(&client);
let home = client.get("/").send().await;
home.assert_ok();
let home = home.text();
assert!(
!home.contains(">News<"),
"a menu item pointing at hidden content must not render a dead link:\n{home}"
);
assert!(
home.contains("Elsewhere"),
"and the rest of the menu is untouched"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_dashboard_count_failure_is_not_reported_as_zero() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Live", "Body.", "publish").await;
client
.get("/admin")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("Posts");
let db = TestDb::shared().await;
try_execute(db, "ALTER TABLE posts RENAME TO posts_hidden")
.await
.expect("hide the table");
let broken = client.get("/admin").header("cookie", &cookie).send().await;
let status = broken.status;
try_execute(db, "ALTER TABLE posts_hidden RENAME TO posts")
.await
.expect("restore the table");
assert_ne!(
status, 200,
"a dashboard that cannot count must say so rather than render zeros"
);
client
.get("/admin")
.header("cookie", &cookie)
.send()
.await
.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_sidebar_renders_a_bounded_number_of_widgets() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
try_execute(
TestDb::shared().await,
"INSERT INTO widgets (sidebar, kind, title, settings, position)
SELECT 'primary', 'text', 'Widget ' || lpad(g::text, 3, '0'),
'{\"text\": \"Body.\"}'::jsonb, g
FROM generate_series(1, 50) AS g",
)
.await
.expect("seed the widgets");
sign_out(&client);
let home = client.get("/").send().await;
home.assert_ok();
let home = home.text();
assert!(home.contains("Widget 001"), "the first widgets render");
assert!(
!home.contains("Widget 031"),
"the sidebar must not render every widget somebody has placed"
);
let screen = client
.get("/admin/appearance")
.header("cookie", &cookie)
.send()
.await;
screen.assert_ok();
let screen = screen.text();
assert!(screen.contains("Widget 001"));
assert!(!screen.contains("Widget 031"));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_widget_cannot_carry_an_unbounded_title_or_body() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let refused = client
.post("/admin/appearance/widgets")
.header("cookie", &cookie)
.form(&form(&[
("kind", "text"),
("title", &"t".repeat(201)),
("text", "Body."),
("position", "0"),
]))
.send()
.await;
assert_eq!(
refused.status,
422,
"an oversized title must be refused: {}",
refused.text()
);
let refused = client
.post("/admin/appearance/widgets")
.header("cookie", &cookie)
.form(&form(&[
("kind", "text"),
("title", "About"),
("text", &"x".repeat(10_001)),
("position", "0"),
]))
.send()
.await;
assert_eq!(
refused.status,
422,
"an oversized body must be refused: {}",
refused.text()
);
let placed: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::widgets::table
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(placed, 0, "neither refused submission may have been stored");
client
.post("/admin/appearance/widgets")
.header("cookie", &cookie)
.form(&form(&[
("kind", "text"),
("title", &"t".repeat(200)),
("text", &"x".repeat(10_000)),
("position", "0"),
]))
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn seeding_skips_a_slug_the_other_bare_type_already_holds() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
for (post_type, title, slug) in [("page", "Hello", "hello-world"), ("post", "About", "about")] {
let created = client
.post(&format!("/admin/content/{post_type}"))
.header("cookie", &cookie)
.form(&form(&[
("title", title),
("slug", slug),
("excerpt", ""),
("body", "Mine, not the seed's."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(created.status, 303, "body: {}", created.text());
}
let author: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::users::table
.select({{crate_name}}::schema::users::id)
.first(&mut conn)
.await
.expect("the owner")
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::seed::seed_posts(&mut conn, author, chrono::Utc::now().naive_utc())
.await
.expect("the seed skips what is already there rather than failing");
}
sign_out(&client);
client
.get("/hello-world")
.send()
.await
.assert_ok()
.assert_body_contains("Mine, not the seed's.");
let taken: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("hello-world"))
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(taken, 1, "the seed must not duplicate a taken bare path");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_term_is_locked_before_its_relationships_are_written() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Filed", "Body.", "publish").await;
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[("name", "Contended"), ("slug", "contended")]))
.send()
.await
.assert_status(303);
let term_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("contended"))
.select({{crate_name}}::schema::terms::id)
.first(&mut conn)
.await
.expect("the term")
};
let mut holder = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query("BEGIN")
.execute(&mut holder)
.await
.expect("begin");
diesel::sql_query(format!(
"SELECT id FROM terms WHERE id = {term_id} FOR UPDATE"
))
.execute(&mut holder)
.await
.expect("hold the term");
let save = tokio::spawn(async move {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::set_post_terms(&mut conn, post_id, vec![term_id]).await
});
let blocked_pid = wait_for_a_blocked_backend().await;
let held = granted_locks(blocked_pid, "post_terms", "RowExclusiveLock").await;
assert_eq!(
held, 0,
"the save must take the term's lock before writing any post_terms row; \
holding one while waiting to upgrade is the deadlock"
);
diesel::sql_query("COMMIT")
.execute(&mut holder)
.await
.expect("commit");
save.await.expect("the task").expect("the save succeeds");
let filed: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::post_terms::table
.filter({{crate_name}}::schema::post_terms::post_id.eq(post_id))
.filter({{crate_name}}::schema::post_terms::term_id.eq(term_id))
.count()
.get_result(&mut conn)
.await
.expect("the count")
};
assert_eq!(filed, 1, "the post is filed under the term");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_post_is_locked_before_its_terms() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Ordered", "Body.", "publish").await;
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[("name", "Ordered"), ("slug", "ordered")]))
.send()
.await
.assert_status(303);
let term_id: i64 = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("ordered"))
.select({{crate_name}}::schema::terms::id)
.first(&mut conn)
.await
.expect("the term")
};
let mut holder = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query("BEGIN")
.execute(&mut holder)
.await
.expect("begin");
diesel::sql_query(format!(
"SELECT id FROM posts WHERE id = {post_id} FOR UPDATE"
))
.execute(&mut holder)
.await
.expect("hold the post");
let save = tokio::spawn(async move {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::set_post_terms(&mut conn, post_id, vec![term_id]).await
});
let blocked_pid = wait_for_a_blocked_backend().await;
let held = granted_locks(blocked_pid, "terms", "RowShareLock").await;
assert_eq!(
held, 0,
"the save must reach the post's lock before it takes any lock on terms"
);
diesel::sql_query("COMMIT")
.execute(&mut holder)
.await
.expect("commit");
save.await.expect("the task").expect("the save succeeds");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_term_deleted_after_resolution_is_dropped_not_a_hard_failure() {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Tagged", "Body.", "publish").await;
for slug in ["kept", "vanishes"] {
client
.post("/admin/terms/category")
.header("cookie", &cookie)
.form(&form(&[("name", slug), ("slug", slug)]))
.send()
.await
.assert_status(303);
}
let (kept_id, vanishing_id): (i64, i64) = {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let kept = {{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("kept"))
.select({{crate_name}}::schema::terms::id)
.first(&mut conn)
.await
.expect("the kept term");
let vanishing = {{crate_name}}::schema::terms::table
.filter({{crate_name}}::schema::terms::slug.eq("vanishes"))
.select({{crate_name}}::schema::terms::id)
.first(&mut conn)
.await
.expect("the vanishing term");
(kept, vanishing)
};
{
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
diesel::delete({{crate_name}}::schema::terms::table.filter({{crate_name}}::schema::terms::id.eq(vanishing_id)))
.execute(&mut conn)
.await
.expect("delete the term out from under the save");
}
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::set_post_terms(&mut conn, post_id, vec![kept_id, vanishing_id])
.await
.expect(
"the save succeeds despite the stale reference, instead of failing its foreign key",
);
let filed: Vec<i64> = {{crate_name}}::schema::post_terms::table
.filter({{crate_name}}::schema::post_terms::post_id.eq(post_id))
.select({{crate_name}}::schema::post_terms::term_id)
.load(&mut conn)
.await
.expect("the post's filed terms");
assert_eq!(
filed,
vec![kept_id],
"the deleted term must be silently dropped, and the still-live one still filed"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_export_reads_one_snapshot() {
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
create_post(&client, &cookie, "Exported", "Body.", "publish").await;
let mut holder = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query("BEGIN")
.execute(&mut holder)
.await
.expect("begin");
diesel::sql_query("LOCK TABLE posts IN ACCESS EXCLUSIVE MODE")
.execute(&mut holder)
.await
.expect("lock posts");
let export = tokio::spawn(async move {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::export_snapshot(&mut conn).await
});
wait_for_a_blocked_backend().await;
try_execute(
TestDb::shared().await,
"INSERT INTO attachments (title, slug, mime_type, byte_size, alt_text, caption)
VALUES ('Late', 'after-the-export-began', 'image/png', 1, '', '')",
)
.await
.expect("insert the late attachment");
diesel::sql_query("COMMIT")
.execute(&mut holder)
.await
.expect("commit");
let rows = export.await.expect("the task").expect("the export");
assert!(
rows.attachments
.iter()
.all(|attachment| attachment.slug != "after-the-export-began"),
"a row committed after the export began must not be in the file"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_page_cannot_leave_the_trash_under_a_trashed_ancestor() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let page = async |title: &str, parent: &str| -> String {
let mut fields = vec![
("title", title),
("slug", ""),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
("taxonomy_names[post_tag]", ""),
];
if !parent.is_empty() {
fields.push(("parent_id", parent));
}
let response = client
.post("/admin/content/page")
.header("cookie", &cookie)
.form(&form(&fields))
.send()
.await;
assert_eq!(response.status, 303, "create: {}", response.text());
response
.header("location")
.expect("redirect")
.rsplit('/')
.next()
.expect("id")
.to_owned()
};
let parent_id = page("Company", "").await;
let child_id = page("Team", &parent_id).await;
let trash = async |id: &str| {
client
.post(&format!("/admin/content/page/{id}/status?to=trash"))
.header("cookie", &cookie)
.send()
.await
};
trash(&child_id).await.assert_status(303);
trash(&parent_id).await.assert_status(303);
let restored = client
.post(&format!("/admin/content/page/{child_id}/status?to=draft"))
.header("cookie", &cookie)
.send()
.await;
assert_ne!(
restored.status, 303,
"restoring under a trashed ancestor must be refused, not redirected"
);
assert!(
restored.text().contains("Company"),
"and must name the ancestor to restore first:\n{}",
restored.text()
);
client
.post(&format!("/admin/content/page/{parent_id}/status?to=draft"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
client
.post(&format!("/admin/content/page/{child_id}/status?to=draft"))
.header("cookie", &cookie)
.send()
.await
.assert_status(303);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_import_never_restores_into_the_trash() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let payload = serde_json::json!({
"version": 2,
"site_title": "Imported",
"exported_at": "2026-01-01T00:00:00Z",
"terms": [],
"posts": [
{
"post_type": "post", "title": "Deleted Elsewhere", "slug": "deleted-elsewhere",
"excerpt": "", "body": "Body.", "status": "trash",
"comment_status": "open", "password": "", "author": "owner",
"published_at": null, "parent": null, "terms": []
}
]
})
.to_string();
import_export(&client, &cookie, payload.as_str())
.await
.assert_ok()
.assert_body_contains("1 imported");
let status: String = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::posts::table
.filter({{crate_name}}::schema::posts::slug.eq("deleted-elsewhere"))
.select({{crate_name}}::schema::posts::status)
.first(&mut conn)
.await
.expect("the imported row")
};
assert_eq!(
status, "draft",
"a trashed row in a file must land as a visible draft"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_hierarchy_lock_comes_before_any_post_row_lock() {
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Doomed", "Body.", "publish").await;
let mut holder = TestDb::shared().await.pool().get().await.expect("conn");
diesel::sql_query("BEGIN")
.execute(&mut holder)
.await
.expect("begin");
diesel::sql_query(format!(
"SELECT pg_advisory_xact_lock({})",
{{crate_name}}::content::PAGE_HIERARCHY_LOCK_KEY
))
.execute(&mut holder)
.await
.expect("hold the hierarchy");
let trash = tokio::spawn(async move {
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::transition_status(&mut conn, post_id, "trash", None, None).await
});
let blocked_pid = wait_for_a_blocked_backend().await;
let held = granted_locks(blocked_pid, "posts", "RowShareLock").await;
assert_eq!(
held, 0,
"the trash must reach the hierarchy lock before it locks the post row"
);
diesel::sql_query("COMMIT")
.execute(&mut holder)
.await
.expect("commit");
trash.await.expect("the task").expect("the trash succeeds");
}
async fn wait_for_a_blocked_backend() -> i32 {
use diesel_async::RunQueryDsl;
#[derive(diesel::QueryableByName)]
struct Pid {
#[diesel(sql_type = diesel::sql_types::Integer)]
pid: i32,
}
for _ in 0..100 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut probe = TestDb::shared().await.pool().get().await.expect("conn");
let waiting: Vec<Pid> = diesel::sql_query(
"SELECT pid FROM pg_locks WHERE NOT granted AND pid <> pg_backend_pid()",
)
.load(&mut probe)
.await
.expect("pg_locks");
if let Some(found) = waiting.into_iter().next() {
return found.pid;
}
}
panic!("nothing blocked on a lock");
}
async fn granted_locks(pid: i32, relation: &str, mode: &str) -> i64 {
use diesel_async::RunQueryDsl;
#[derive(diesel::QueryableByName)]
struct Count {
#[diesel(sql_type = diesel::sql_types::BigInt)]
n: i64,
}
let mut probe = TestDb::shared().await.pool().get().await.expect("conn");
let rows: Vec<Count> = diesel::sql_query(format!(
"SELECT count(*) AS n
FROM pg_locks l JOIN pg_class c ON c.oid = l.relation
WHERE l.pid = {pid} AND l.granted
AND c.relname = '{relation}' AND l.mode = '{mode}'"
))
.load(&mut probe)
.await
.expect("pg_locks");
rows.into_iter().next().map_or(0, |row| row.n)
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_comment_the_page_cannot_render_is_not_anchored() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Overfull", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
VALUES ({post_id}, NULL, 1, 'Owner', 'owner@example.com', '', '',
'The root', 'approved', NOW())"
),
)
.await
.expect("seed the root");
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
SELECT {post_id}, (SELECT id FROM comments WHERE body = 'The root'),
1, 'Owner', 'owner@example.com', '', '',
'Reply ' || lpad(g::text, 5, '0'), 'approved',
NOW() - ((2000 - g) || ' seconds')::interval
FROM generate_series(1, 1500) AS g"
),
)
.await
.expect("seed the replies");
let root_id: i64 = {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::schema::comments::table
.filter({{crate_name}}::schema::comments::body.eq("The root"))
.select({{crate_name}}::schema::comments::id)
.first(&mut conn)
.await
.expect("the root")
};
let posted = client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[
("body", "One reply too many"),
("reply_to", &root_id.to_string()),
]))
.send()
.await;
posted
.assert_status(422)
.assert_body_contains("This conversation has reached its display limit");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn comment_urls_survive_the_plain_permalink_structure() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Plain Thread", "Body.", "publish").await;
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[("permalink_structure", "plain")]))
.send()
.await
.assert_status(303);
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
SELECT {post_id}, NULL, 1, 'Owner', 'owner@example.com', '', '',
'Plain ' || lpad(g::text, 3, '0'), 'approved',
NOW() - ((100 - g) || ' minutes')::interval
FROM generate_series(1, 60) AS g"
),
)
.await
.expect("seed the roots");
let first = client.get(&format!("/?p={post_id}")).send().await;
let html = first.assert_ok().text();
assert!(
html.contains(&format!("/?p={post_id}&comments=2")),
"the pager must extend the existing query, not start a second one:\n{html}"
);
let second = client
.get(&format!("/?p={post_id}&comments=2"))
.send()
.await;
second
.assert_ok()
.assert_body_contains("Plain 060")
.assert_body_contains("Page 2 of 2");
client
.post("/admin/settings")
.header("cookie", &cookie)
.form(&settings_form(&[
("permalink_structure", "plain"),
("comment_moderation", "on"),
]))
.send()
.await
.assert_status(303);
sign_out(&client);
let posted = client
.post(&format!("/comments/{post_id}"))
.form(&form(&[
("body", "A guest comment"),
("author_name", "Guest"),
("author_email", "guest@example.com"),
]))
.send()
.await;
let location = posted
.assert_status(303)
.header("location")
.expect("a redirect");
assert_eq!(
location,
format!("/?p={post_id}&moderated=1"),
"the marker must be a second parameter, not part of `p`"
);
client
.get(location)
.send()
.await
.assert_ok()
.assert_body_contains("awaiting moderation");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_new_comment_redirects_to_the_page_that_holds_it() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Deep Thread", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
SELECT {post_id}, NULL, 1, 'Owner', 'owner@example.com', '', '',
'Seed ' || lpad(g::text, 3, '0'), 'approved',
NOW() - ((100 - g) || ' minutes')::interval
FROM generate_series(1, 50) AS g"
),
)
.await
.expect("seed a full page of roots");
let posted = client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[("body", "The fifty-first root")]))
.send()
.await;
let location = posted
.assert_status(303)
.header("location")
.expect("a redirect");
assert!(
location.contains("comments=2"),
"a root past the first page belongs on page two: {location}"
);
let anchor = location
.rsplit_once('#')
.expect("an anchor to the new comment")
.1
.to_owned();
let landed = client.get(location).send().await;
let html = landed.assert_ok().text();
assert!(
html.contains(&format!("id=\"{anchor}\"")),
"the anchor must exist on the page the redirect chose:\n{html}"
);
let reply = client
.post(&format!("/comments/{post_id}"))
.header("cookie", &cookie)
.form(&form(&[
("body", "A reply to the first root"),
("reply_to", "1"),
]))
.send()
.await;
let reply_location = reply
.assert_status(303)
.header("location")
.expect("a redirect");
assert!(
!reply_location.contains("comments="),
"a reply to a root on page one belongs on page one: {reply_location}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn one_popular_root_cannot_unbound_a_comment_page() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Popular", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
VALUES ({post_id}, NULL, 1, 'Owner', 'owner@example.com', '', '',
'The root', 'approved', NOW())"
),
)
.await
.expect("seed the root");
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
SELECT {post_id}, (SELECT id FROM comments WHERE body = 'The root'),
1, 'Owner', 'owner@example.com', '', '',
'Reply ' || lpad(g::text, 5, '0'), 'approved',
NOW() + (g || ' seconds')::interval
FROM generate_series(1, 2500) AS g"
),
)
.await
.expect("seed the replies");
sign_out(&client);
let page = client.get("/popular").send().await;
let html = page.assert_ok().text();
let rendered = html.matches("id=\"comment-").count();
assert!(
rendered <= 1000,
"a page must stay bounded whatever one root collects, rendered {rendered}"
);
assert!(
html.contains("some replies are not shown"),
"and must say so rather than pretending the thread is complete:\n{}",
&html[..html.len().min(4000)]
);
assert!(
html.contains("The root") && html.contains("Reply 00001"),
"the oldest replies are the ones kept"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn every_approved_comment_stays_reachable() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Busy Thread", "Body.", "publish").await;
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
SELECT {post_id}, NULL, 1, 'Owner', 'owner@example.com', '', '',
'Root ' || lpad(g::text, 3, '0'), 'approved',
NOW() - ((100 - g) || ' minutes')::interval
FROM generate_series(1, 60) AS g"
),
)
.await
.expect("seed the roots");
try_execute(
TestDb::shared().await,
&format!(
"INSERT INTO comments (post_id, parent_id, author_id, author_name, author_email,
author_url, author_ip, body, status, created_at)
SELECT {post_id},
(SELECT id FROM comments WHERE body = 'Root 060'),
1, 'Owner', 'owner@example.com', '', '',
'A late reply', 'approved', NOW()"
),
)
.await
.expect("seed the reply");
sign_out(&client);
let first = client.get("/busy-thread").send().await;
first.assert_ok();
let first = first.text();
assert!(
first.contains("Root 001"),
"the first page starts at the top"
);
assert!(
!first.contains("Root 060"),
"and stops at the page size rather than the whole thread"
);
assert!(first.contains("Page 1 of 2"), "with a pager:\n{first}");
let second = client.get("/busy-thread?comments=2").send().await;
second.assert_ok();
let second = second.text();
assert!(second.contains("Root 060"), "the tail is reachable");
assert!(
second.contains("A late reply"),
"and a reply renders on the page its root is on:\n{second}"
);
assert!(!second.contains("Root 001"), "page two is not page one");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn import_comments_ignores_unrelated_existing_comments() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Thread", "Body.", "publish").await;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
{{crate_name}}::content::create_comment(
&mut conn,
{{crate_name}}::models::NewComment {
post_id,
parent_id: None,
author_id: None,
author_name: "Visitor".to_owned(),
author_email: "visitor@example.com".to_owned(),
author_url: String::new(),
author_ip: String::new(),
body: "Unrelated visitor comment".to_owned(),
status: "approved".to_owned(),
},
"",
)
.await
.expect("visitor comment");
let incoming = vec![{{crate_name}}::content::ImportedComment {
author_username: None,
author_name: "Archivist".to_owned(),
author_email: "archivist@example.com".to_owned(),
author_url: String::new(),
body: "From the backup".to_owned(),
status: "approved".to_owned(),
created_at: chrono::NaiveDate::from_ymd_opt(2024, 1, 15)
.expect("date")
.and_hms_opt(12, 0, 0)
.expect("time"),
replies: vec![],
}];
let created = {{crate_name}}::content::import_comments(&mut conn, post_id, &incoming)
.await
.expect("import");
assert_eq!(created, 1, "the backup's thread must not be dropped");
let again = {{crate_name}}::content::import_comments(&mut conn, post_id, &incoming)
.await
.expect("retry");
assert_eq!(again, 0, "a re-run must not append a second copy");
}
async fn create_page(client: &TestClient, cookie: &str, title: &str) -> i64 {
let resp = client
.post("/admin/content/page")
.header("cookie", cookie)
.form(&form(&[
("title", title),
("slug", ""),
("excerpt", ""),
("body", "Body."),
("status", "publish"),
("password", ""),
]))
.send()
.await;
assert_eq!(resp.status, 303, "create should redirect: {}", resp.text());
resp.header("location")
.expect("redirect to the editor")
.rsplit('/')
.next()
.expect("id is the last path segment")
.parse()
.expect("id is numeric")
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn set_post_parent_distinguishes_refusal_from_failure() {
let client = db_client().await;
let cookie = register(&client, "owner").await;
let parent = create_page(&client, &cookie, "Parent").await;
let child = create_page(&client, &cookie, "Child").await;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
assert!(
{{crate_name}}::content::set_post_parent(&mut conn, child, parent)
.await
.expect("a valid link applies"),
"the link must be applied"
);
assert!(
!{{crate_name}}::content::set_post_parent(&mut conn, parent, child)
.await
.expect("a refusal is not an error"),
"a cycle must decline the link, not fail the import"
);
assert!(
!{{crate_name}}::content::set_post_parent(&mut conn, child, 999_999_999)
.await
.expect("a refusal is not an error"),
"a missing parent must decline the link, not fail the import"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_reply_past_the_display_budget_is_refused() {
use {{crate_name}}::schema::comments;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Crowded", "Body.", "publish").await;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let root_id: i64 = diesel::insert_into(comments::table)
.values((
comments::post_id.eq(post_id),
comments::parent_id.eq(None::<i64>),
comments::author_id.eq(None::<i64>),
comments::author_name.eq("Crowd"),
comments::author_email.eq("crowd@example.com"),
comments::author_url.eq(""),
comments::author_ip.eq(""),
comments::body.eq("Root"),
comments::status.eq("approved"),
))
.returning(comments::id)
.get_result(&mut conn)
.await
.expect("seed root");
let children: Vec<_> = (0..999)
.map(|i| {
(
comments::post_id.eq(post_id),
comments::parent_id.eq(Some(root_id)),
comments::author_id.eq(None::<i64>),
comments::author_name.eq("Crowd"),
comments::author_email.eq("crowd@example.com"),
comments::author_url.eq(""),
comments::author_ip.eq(""),
comments::body.eq(format!("Child {i}")),
comments::status.eq("approved"),
)
})
.collect();
diesel::insert_into(comments::table)
.values(&children)
.execute(&mut conn)
.await
.expect("seed children");
let refused = {{crate_name}}::content::create_comment(
&mut conn,
{{crate_name}}::models::NewComment {
post_id,
parent_id: Some(root_id),
author_id: None,
author_name: "Latecomer".to_owned(),
author_email: "late@example.com".to_owned(),
author_url: String::new(),
author_ip: String::new(),
body: "One too many".to_owned(),
status: "approved".to_owned(),
},
"",
)
.await;
assert!(
refused.is_err(),
"a reply past the display budget must be refused, not accepted and left unreadable"
);
let pending_id: i64 = diesel::insert_into(comments::table)
.values((
comments::post_id.eq(post_id),
comments::parent_id.eq(Some(root_id)),
comments::author_id.eq(None::<i64>),
comments::author_name.eq("Waiting"),
comments::author_email.eq("waiting@example.com"),
comments::author_url.eq(""),
comments::author_ip.eq(""),
comments::body.eq("Pending past the budget"),
comments::status.eq("pending"),
))
.returning(comments::id)
.get_result(&mut conn)
.await
.expect("seed pending");
let approval = {{crate_name}}::content::moderate_comment(&mut conn, pending_id, "approved").await;
assert!(
approval.is_err(),
"approving a reply with no renderable window must be refused"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn moderation_queue_renders_a_pager() {
use {{crate_name}}::schema::comments;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let client = db_client().await;
let cookie = register(&client, "owner").await;
let post_id = create_post(&client, &cookie, "Flood", "Body.", "publish").await;
let mut conn = TestDb::shared().await.pool().get().await.expect("conn");
let pending: Vec<_> = (0..51)
.map(|i| {
(
comments::post_id.eq(post_id),
comments::parent_id.eq(None::<i64>),
comments::author_id.eq(None::<i64>),
comments::author_name.eq("Spammer"),
comments::author_email.eq("spam@example.com"),
comments::author_url.eq(""),
comments::author_ip.eq(""),
comments::body.eq(format!("Spam {i}")),
comments::status.eq("pending"),
)
})
.collect();
diesel::insert_into(comments::table)
.values(&pending)
.execute(&mut conn)
.await
.expect("seed queue");
let first = client
.get("/admin/comments?status=pending")
.header("cookie", &cookie)
.send()
.await;
first
.assert_ok()
.assert_body_contains("Page 1 of 2")
.assert_body_contains("/admin/comments?status=pending&page=2");
let stale = client
.get("/admin/comments?status=pending&page=99")
.header("cookie", &cookie)
.send()
.await;
stale
.assert_ok()
.assert_body_contains("Page 2 of 2")
.assert_body_contains("/admin/comments?status=pending&page=1");
}