added testing
This commit is contained in:
+129
-41
@@ -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 ( in the markdown body).".to_string(),
|
||||
"A gallery entry must include at least one image ( 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\
|
||||
\n\
|
||||
```\n\
|
||||
\n\
|
||||
```\n\
|
||||
";
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user