added testing

This commit is contained in:
2026-05-16 23:48:57 +02:00
parent 23c62fb1e6
commit f1d5c4a4fd
16 changed files with 1014 additions and 119 deletions
+129 -41
View File
@@ -21,9 +21,8 @@ const WORDS_PER_MINUTE: u32 = 200;
const MAX_SLUG_LEN: usize = 100;
const WINDOWS_RESERVED: &[&str] = &[
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn validate_slug(s: &str) -> Result<(), AppError> {
@@ -47,9 +46,7 @@ fn validate_slug(s: &str) -> Result<(), AppError> {
));
}
if s.contains("..") {
return Err(AppError::BadRequest(
"Slug cannot contain '..'".to_string(),
));
return Err(AppError::BadRequest("Slug cannot contain '..'".to_string()));
}
for c in s.chars() {
if c.is_control() {
@@ -66,15 +63,15 @@ fn validate_slug(s: &str) -> Result<(), AppError> {
}
let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase();
if WINDOWS_RESERVED.iter().any(|r| *r == stem) {
return Err(AppError::BadRequest(
"Slug is a reserved name".to_string(),
));
return Err(AppError::BadRequest("Slug is a reserved name".to_string()));
}
Ok(())
}
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?;
let raw = raw
.strip_prefix("---\n")
.or_else(|| raw.strip_prefix("---\r\n"))?;
let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?;
let yaml = &raw[..end_marker];
let body_start = end_marker
@@ -82,7 +79,9 @@ fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
.find("---\n")
.or_else(|| raw[end_marker..].find("---\r\n"))?
+ "---\n".len();
let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r');
let body = raw[body_start..]
.trim_start_matches('\n')
.trim_start_matches('\r');
Some((yaml, body))
}
@@ -103,12 +102,8 @@ fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
}
fn serialize_post(meta: &PostMeta, body: &str) -> Result<String, AppError> {
let yaml = serde_yaml::to_string(meta).map_err(|e| {
AppError::Internal(
"Serialization error".to_string(),
Some(e.to_string()),
)
})?;
let yaml = serde_yaml::to_string(meta)
.map_err(|e| AppError::Internal("Serialization error".to_string(), Some(e.to_string())))?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
@@ -176,11 +171,7 @@ fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
/// bytes via `imagesize::size`, off the runtime via `spawn_blocking`.
async fn compute_dim_from_url(state: &AppState, url: &str) -> Option<ImageDim> {
let name = url.strip_prefix("/uploads/")?;
if name.is_empty()
|| name.contains("..")
|| name.contains('\\')
|| name.starts_with('/')
{
if name.is_empty() || name.contains("..") || name.contains('\\') || name.starts_with('/') {
return None;
}
let path = state.data_dir.join("uploads").join(name);
@@ -254,9 +245,7 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> String {
return s.trim().to_string();
}
}
let plain = body
.replace(['#', '*', '_', '`'], "")
.replace('\n', " ");
let plain = body.replace(['#', '*', '_', '`'], "").replace('\n', " ");
let mut out: String = plain.chars().take(200).collect();
if plain.chars().count() > 200 {
out.push_str("...");
@@ -340,17 +329,16 @@ pub async fn rebuild_posts_cache(state: &AppState) {
*state.posts_cache.write().await = posts;
}
async fn write_post_atomic(
state: &AppState,
slug: &str,
contents: &str,
) -> Result<(), AppError> {
async fn write_post_atomic(state: &AppState, slug: &str, contents: &str) -> Result<(), AppError> {
let _guard = state.post_lock.lock().await;
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents).await.map_err(|e| {
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
let tmp_path = state
.data_dir
.join("posts")
.join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents)
.await
.map_err(|e| AppError::Internal("Write error".to_string(), Some(e.to_string())))?;
if let Err(e) = fs::rename(&tmp_path, &final_path).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(AppError::Internal(
@@ -407,13 +395,17 @@ pub async fn create_post(
let images = extract_images(&payload.content);
if images.is_empty() {
return Err(AppError::BadRequest(
"A gallery entry must include at least one image (![](url) in the markdown body).".to_string(),
"A gallery entry must include at least one image (![](url) in the markdown body)."
.to_string(),
));
}
let meta = PostMeta {
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
title: payload
.title
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty()),
summary: payload.summary.filter(|s| !s.trim().is_empty()),
tags: payload.tags,
draft: payload.draft,
@@ -515,7 +507,11 @@ async fn neighbors_from_cache(
slug: p.slug.clone(),
title: p.title.clone(),
};
let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None };
let prev = if i > 0 {
Some(to_neighbor(visible[i - 1]))
} else {
None
};
let next = visible.get(i + 1).map(|p| to_neighbor(p));
(prev, next)
}
@@ -541,10 +537,7 @@ pub async fn get_post(
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
let image_urls: Vec<String> = extract_images(&body)
.into_iter()
.map(|(_, u)| u)
.collect();
let image_urls: Vec<String> = extract_images(&body).into_iter().map(|(_, u)| u).collect();
let dimensions = dims_for_urls(&state, &image_urls).await;
Ok(Json(PostDetail {
@@ -563,3 +556,98 @@ pub async fn get_post(
dimensions,
}))
}
#[cfg(test)]
mod tests {
use super::{
cover_from, extract_images, parse_post, reading_time, split_frontmatter, validate_slug,
};
use crate::error::AppError;
#[test]
fn validate_slug_accepts_normal_slugs() {
assert!(validate_slug("hello-world").is_ok());
assert!(validate_slug("a_b.c-123").is_ok());
}
#[test]
fn validate_slug_rejects_traversal_and_bad_chars() {
for bad in [
"",
"../etc",
"with/slash",
"back\\slash",
"ends.",
"trailing ",
".hidden",
] {
assert!(
matches!(validate_slug(bad), Err(AppError::BadRequest(_))),
"expected {bad:?} to be rejected"
);
}
let too_long = "x".repeat(101);
assert!(validate_slug(&too_long).is_err());
assert!(matches!(validate_slug("CON"), Err(AppError::BadRequest(_))));
}
#[test]
fn split_frontmatter_handles_lf_and_crlf() {
let (yaml, body) = split_frontmatter("---\ndate: 2026-05-16\n---\nHello").unwrap();
assert_eq!(yaml, "date: 2026-05-16");
assert_eq!(body, "Hello");
let (y2, b2) = split_frontmatter("---\r\ndate: 2026-05-16\r\n---\r\nHi").unwrap();
assert!(y2.contains("date: 2026-05-16"));
assert_eq!(b2, "Hi");
assert!(split_frontmatter("no frontmatter here").is_none());
}
#[test]
fn parse_post_reads_meta_and_body() {
let raw = "---\ndate: 2026-05-16\ntitle: Hello\ndraft: true\n---\nBody text";
let (meta, body) = parse_post(raw).unwrap();
assert_eq!(meta.title.as_deref(), Some("Hello"));
assert!(meta.draft);
assert_eq!(meta.date.to_string(), "2026-05-16");
assert_eq!(body, "Body text");
assert!(parse_post("no frontmatter").is_err());
}
#[test]
fn reading_time_rounds_up_by_wpm() {
assert_eq!(reading_time(""), 0);
assert_eq!(reading_time("one"), 1);
assert_eq!(reading_time(&"word ".repeat(200)), 1);
assert_eq!(reading_time(&"word ".repeat(201)), 2);
}
#[test]
fn extract_images_skips_fences_and_strips_titles() {
let md = "intro\n\
![a](/u/one.png)\n\
```\n\
![skip](/u/hidden.png)\n\
```\n\
![c](/u/two.png \"a title\")";
let imgs = extract_images(md);
assert_eq!(
imgs,
vec![
("a".to_string(), "/u/one.png".to_string()),
("c".to_string(), "/u/two.png".to_string()),
]
);
}
#[test]
fn cover_from_takes_first_or_none() {
assert!(cover_from(&[]).is_none());
let imgs = vec![("alt".to_string(), "/u/first.png".to_string())];
let cover = cover_from(&imgs).unwrap();
assert_eq!(cover.url, "/u/first.png");
assert_eq!(cover.alt, "alt");
}
}