test: add 122 unit tests across 12 modules

Cover utils/text, api/slug, auth/password, auth/session,
models/post, models/user, api/auth validation, api/markdown,
api/image, highlight, api/rate_limit. Add make test target.
This commit is contained in:
xfy 2026-06-09 09:25:44 +08:00
parent 959d813630
commit 4f368e6fb8
13 changed files with 856 additions and 1 deletions

View File

@ -36,6 +36,9 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
[dev-dependencies]
tokio = { version = "1.52", features = ["full"] }
[profile.release]
debug = false
opt-level = "z"

View File

@ -1,4 +1,4 @@
.PHONY: dev build css css-watch clean build-editor highlight-css
.PHONY: dev build css css-watch clean build-editor highlight-css test
build:
@$(MAKE) build-editor
@ -27,6 +27,9 @@ css:
css-watch:
@tailwindcss -i input.css -o public/style.css --watch
test:
@cargo test
clean:
@cargo clean
@rm -f public/style.css

View File

@ -309,3 +309,78 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
Ok(CurrentUserResponse { user })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_username_valid() {
assert!(validate_username("admin").is_ok());
assert!(validate_username("user_123").is_ok());
assert!(validate_username("abc").is_ok());
}
#[test]
fn validate_username_too_short() {
assert!(validate_username("ab").is_err());
}
#[test]
fn validate_username_too_long() {
assert!(validate_username(&"a".repeat(51)).is_err());
}
#[test]
fn validate_username_max_length() {
assert!(validate_username(&"a".repeat(50)).is_ok());
}
#[test]
fn validate_username_special_chars() {
assert!(validate_username("user name").is_err());
assert!(validate_username("user@name").is_err());
assert!(validate_username("user-name").is_err());
}
#[test]
fn validate_username_unicode() {
assert!(validate_username("用户名").is_ok());
}
#[test]
fn validate_email_valid() {
assert!(validate_email("user@example.com").is_ok());
assert!(validate_email("a.b+c@domain.co").is_ok());
}
#[test]
fn validate_email_invalid() {
assert!(validate_email("notanemail").is_err());
assert!(validate_email("@domain.com").is_err());
assert!(validate_email("user@").is_err());
assert!(validate_email("user@.com").is_err());
assert!(validate_email("").is_err());
}
#[test]
fn validate_password_valid() {
assert!(validate_password("12345678").is_ok());
assert!(validate_password("a very long password with spaces").is_ok());
}
#[test]
fn validate_password_too_short() {
assert!(validate_password("1234567").is_err());
}
#[test]
fn validate_password_exactly_8() {
assert!(validate_password("12345678").is_ok());
}
#[test]
fn validate_password_empty() {
assert!(validate_password("").is_err());
}
}

View File

@ -296,3 +296,153 @@ pub async fn serve_image(Path(path): Path<String>, Query(params): Query<ImagePar
)
.into_response()
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn image_params_validate_valid_defaults() {
let params = ImageParams::default();
assert!(params.validate().is_ok());
}
#[test]
fn image_params_validate_valid_width() {
let params = ImageParams { w: Some(100), ..Default::default() };
assert!(params.validate().is_ok());
}
#[test]
fn image_params_validate_zero_width_rejected() {
let params = ImageParams { w: Some(0), ..Default::default() };
assert!(params.validate().is_err());
}
#[test]
fn image_params_validate_oversized_width_rejected() {
let params = ImageParams { w: Some(5000), ..Default::default() };
assert!(params.validate().is_err());
}
#[test]
fn image_params_validate_valid_rotation() {
for angle in [0, 90, 180, 270] {
let params = ImageParams { rotate: Some(angle), ..Default::default() };
assert!(params.validate().is_ok(), "angle {} should be valid", angle);
}
}
#[test]
fn image_params_validate_invalid_rotation_rejected() {
let params = ImageParams { rotate: Some(45), ..Default::default() };
assert!(params.validate().is_err());
}
#[test]
fn image_params_validate_valid_format() {
for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] {
let params = ImageParams { format: Some(fmt.to_string()), ..Default::default() };
assert!(params.validate().is_ok(), "format {} should be valid", fmt);
}
}
#[test]
fn image_params_validate_invalid_format_rejected() {
let params = ImageParams { format: Some("gif".to_string()), ..Default::default() };
assert!(params.validate().is_err());
}
#[test]
fn image_params_validate_valid_thumbnail() {
let params = ImageParams { thumb: Some("200x150".to_string()), ..Default::default() };
assert!(params.validate().is_ok());
}
#[test]
fn image_params_validate_invalid_thumbnail_rejected() {
let params = ImageParams { thumb: Some("200".to_string()), ..Default::default() };
assert!(params.validate().is_err());
}
#[test]
fn image_params_validate_valid_quality() {
let params = ImageParams { quality: Some(85), ..Default::default() };
assert!(params.validate().is_ok());
}
#[test]
fn image_params_validate_zero_quality_rejected() {
let params = ImageParams { quality: Some(0), ..Default::default() };
assert!(params.validate().is_err());
}
#[test]
fn image_params_validate_over_100_quality_rejected() {
let params = ImageParams { quality: Some(101), ..Default::default() };
assert!(params.validate().is_err());
}
#[test]
fn is_path_safe_normal() {
assert!(is_path_safe("images/photo.jpg"));
assert!(is_path_safe("2024/01/photo.png"));
}
#[test]
fn is_path_safe_rejects_parent_dir() {
assert!(!is_path_safe("../etc/passwd"));
assert!(!is_path_safe("foo/../../bar"));
}
#[test]
fn is_path_safe_rejects_null_bytes() {
assert!(!is_path_safe("foo\0bar"));
}
#[test]
fn is_path_safe_rejects_absolute_path() {
assert!(!is_path_safe("/etc/passwd"));
}
#[test]
fn detect_format_jpeg() {
assert!(matches!(detect_format("photo.jpg"), image::ImageFormat::Jpeg));
assert!(matches!(detect_format("photo.jpeg"), image::ImageFormat::Jpeg));
assert!(matches!(detect_format("PHOTO.JPG"), image::ImageFormat::Jpeg));
}
#[test]
fn detect_format_png() {
assert!(matches!(detect_format("icon.png"), image::ImageFormat::Png));
}
#[test]
fn detect_format_webp() {
assert!(matches!(detect_format("anim.webp"), image::ImageFormat::WebP));
}
#[test]
fn detect_format_defaults_to_jpeg() {
assert!(matches!(detect_format("file.xyz"), image::ImageFormat::Jpeg));
}
#[test]
fn cache_key_differs_for_different_params() {
let p1 = ImageParams { w: Some(100), ..Default::default() };
let p2 = ImageParams { w: Some(200), ..Default::default() };
assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg"));
}
#[test]
fn is_empty_true_when_all_none() {
let params = ImageParams::default();
assert!(params.is_empty());
}
#[test]
fn is_empty_false_when_any_set() {
let params = ImageParams { w: Some(100), ..Default::default() };
assert!(!params.is_empty());
}
}

View File

@ -266,3 +266,122 @@ fn slugify_heading(text: &str) -> String {
slug
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn slugify_heading_simple() {
assert_eq!(slugify_heading("Hello World"), "hello-world");
}
#[test]
fn slugify_heading_special_chars() {
assert_eq!(slugify_heading("What's new? (2024)"), "what-s-new-2024");
}
#[test]
fn slugify_heading_chinese() {
let slug = slugify_heading("你好世界");
assert!(!slug.is_empty());
}
#[test]
fn slugify_heading_collapses_dashes() {
assert_eq!(slugify_heading("a--b"), "a-b");
}
#[test]
fn slugify_heading_strips_trailing_dash() {
assert_eq!(slugify_heading("hello!"), "hello");
}
#[test]
fn slugify_heading_empty_returns_heading() {
assert_eq!(slugify_heading(""), "heading");
}
#[test]
fn clean_html_allows_safe_tags() {
let input = "<p>Hello <strong>world</strong></p>";
assert_eq!(clean_html(input), input);
}
#[test]
fn clean_html_removes_script() {
let input = "<script>alert('xss')</script><p>safe</p>";
let result = clean_html(input);
assert!(!result.contains("script"));
assert!(result.contains("safe"));
}
#[test]
fn clean_html_allows_id_attribute() {
let input = "<h2 id=\"my-heading\">Title</h2>";
let result = clean_html(input);
assert!(result.contains("id=\"my-heading\""));
}
#[test]
fn clean_html_allows_class_attribute() {
let input = "<span class=\"highlight\">text</span>";
let result = clean_html(input);
assert!(result.contains("class=\"highlight\""));
}
#[test]
fn generate_toc_html_empty() {
assert_eq!(generate_toc_html(&[]), "");
}
#[test]
fn generate_toc_html_single_heading() {
let headings = vec![(2u8, "Title".to_string(), "title".to_string())];
let html = generate_toc_html(&headings);
assert!(html.contains("href=\"#title\""));
assert!(html.contains("<ul>"));
assert!(html.contains("</ul>"));
}
#[test]
fn generate_toc_html_nested() {
let headings = vec![
(2u8, "Chapter".to_string(), "chapter".to_string()),
(3u8, "Section".to_string(), "section".to_string()),
];
let html = generate_toc_html(&headings);
assert!(html.contains("href=\"#chapter\""));
assert!(html.contains("href=\"#section\""));
let ul_count = html.matches("<ul>").count();
assert_eq!(ul_count, 2);
}
#[test]
fn render_markdown_simple_paragraph() {
let result = render_markdown_enhanced("Hello **world**");
assert!(result.html.contains("<strong>world</strong>"));
assert!(result.toc_html.is_empty());
}
#[test]
fn render_markdown_with_heading_generates_toc() {
let result = render_markdown_enhanced("## My Heading\n\nSome text.");
assert!(result.toc_html.contains("My Heading"));
assert!(result.html.contains("id=\"my-heading\""));
}
#[test]
fn render_markdown_empty() {
let result = render_markdown_enhanced("");
assert_eq!(result.html, "");
assert_eq!(result.toc_html, "");
}
#[test]
fn render_markdown_code_block() {
let result = render_markdown_enhanced("```rust\nfn main() {}\n```");
assert!(result.html.contains("<pre><code>"));
assert!(result.html.contains("main"));
}
}

View File

@ -87,3 +87,37 @@ pub fn check_upload_limit(ip: &str) -> Result<(), String> {
.map(|_| ())
.map_err(|_| "上传过于频繁,请稍后再试".to_string())
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use http::HeaderMap;
#[test]
fn get_client_ip_from_x_forwarded_for() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
assert_eq!(get_client_ip(&headers), "1.2.3.4");
}
#[test]
fn get_client_ip_from_x_real_ip() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
assert_eq!(get_client_ip(&headers), "9.8.7.6");
}
#[test]
fn get_client_ip_x_forwarded_for_takes_priority() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.1.1.1".parse().unwrap());
headers.insert("x-real-ip", "2.2.2.2".parse().unwrap());
assert_eq!(get_client_ip(&headers), "1.1.1.1");
}
#[test]
fn get_client_ip_no_headers_returns_unknown() {
let headers = HeaderMap::new();
assert_eq!(get_client_ip(&headers), "unknown");
}
}

View File

@ -79,3 +79,80 @@ pub async fn ensure_unique_slug(
}
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn slugify_ascii_title() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn slugify_special_characters() {
assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
}
#[test]
fn slugify_chinese_characters() {
let slug = slugify("你好世界 hello");
assert!(slug.contains("hello"));
}
#[test]
fn slugify_collapses_dashes() {
assert_eq!(slugify("a---b"), "a-b");
}
#[test]
fn slugify_empty_returns_timestamp() {
let slug = slugify("");
let _: i64 = slug.parse().expect("should be a valid timestamp");
}
#[test]
fn slugify_truncates_at_100_chars() {
let long_title = "a".repeat(200);
assert!(slugify(&long_title).len() <= 100);
}
#[test]
fn slugify_preserves_underscores() {
assert_eq!(slugify("hello_world"), "hello_world");
}
#[test]
fn is_valid_slug_normal() {
assert!(is_valid_slug("hello-world_123"));
}
#[test]
fn is_valid_slug_rejects_empty() {
assert!(!is_valid_slug(""));
}
#[test]
fn is_valid_slug_rejects_too_long() {
let long_slug = "a".repeat(201);
assert!(!is_valid_slug(&long_slug));
}
#[test]
fn is_valid_slug_accepts_max_length() {
let slug = "a".repeat(200);
assert!(is_valid_slug(&slug));
}
#[test]
fn is_valid_slug_rejects_special_chars() {
assert!(!is_valid_slug("hello world"));
assert!(!is_valid_slug("hello.world"));
assert!(!is_valid_slug("hello!world"));
}
#[test]
fn is_valid_slug_accepts_chinese() {
assert!(is_valid_slug("你好-world"));
}
}

View File

@ -22,3 +22,46 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::passw
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_roundtrip() {
let hash = hash_password("mypassword123").unwrap();
assert!(verify_password("mypassword123", &hash).unwrap());
}
#[test]
fn verify_wrong_password_returns_false() {
let hash = hash_password("correctpassword").unwrap();
assert!(!verify_password("wrongpassword", &hash).unwrap());
}
#[test]
fn different_hashes_for_same_password() {
let hash1 = hash_password("samepassword").unwrap();
let hash2 = hash_password("samepassword").unwrap();
assert_ne!(hash1, hash2);
}
#[test]
fn verify_invalid_hash_returns_error() {
let result = verify_password("password", "not-a-valid-hash");
assert!(result.is_err());
}
#[test]
fn hash_empty_password() {
let hash = hash_password("").unwrap();
assert!(verify_password("", &hash).unwrap());
}
#[test]
fn hash_long_password() {
let long_pw = "a".repeat(1000);
let hash = hash_password(&long_pw).unwrap();
assert!(verify_password(&long_pw, &hash).unwrap());
}
}

View File

@ -39,3 +39,64 @@ pub fn get_session_from_ctx() -> Option<String> {
.map(|s| s.to_string())
})
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn parse_session_found() {
let header = "session=abc123; path=/";
assert_eq!(parse_session_token(header), Some("abc123"));
}
#[test]
fn parse_session_single_cookie() {
assert_eq!(parse_session_token("session=token456"), Some("token456"));
}
#[test]
fn parse_session_not_found() {
assert_eq!(parse_session_token("other=value"), None);
}
#[test]
fn parse_session_empty_string() {
assert_eq!(parse_session_token(""), None);
}
#[test]
fn parse_session_multiple_cookies() {
let header = "theme=dark; session=my-secret; lang=en";
assert_eq!(parse_session_token(header), Some("my-secret"));
}
#[test]
fn parse_session_empty_value() {
assert_eq!(parse_session_token("session="), Some(""));
}
#[test]
fn parse_session_trailing_semicolon() {
assert_eq!(parse_session_token("session=abc;"), Some("abc"));
}
#[test]
fn generate_token_is_uuid() {
let token = generate_token();
assert!(uuid::Uuid::parse_str(&token).is_ok());
}
#[test]
fn default_expiry_is_future() {
let expiry = default_expiry();
assert!(expiry > chrono::Utc::now());
}
#[test]
fn default_expiry_is_about_30_days() {
let expiry = default_expiry();
let diff = expiry - chrono::Utc::now();
assert!(diff.num_days() >= 29 && diff.num_days() <= 31);
}
}

View File

@ -84,3 +84,52 @@ pub mod server {
generator.finalize()
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::server::*;
#[test]
fn highlight_code_rust() {
let result = highlight_code("fn main() {}", Some("rust"));
assert!(!result.is_empty());
assert!(result.contains("main"));
}
#[test]
fn highlight_code_javascript_alias() {
let result = highlight_code("console.log('hi')", Some("js"));
assert!(!result.is_empty());
assert!(result.contains("console"));
}
#[test]
fn highlight_code_python_alias() {
let result = highlight_code("print('hi')", Some("python"));
assert!(!result.is_empty());
}
#[test]
fn highlight_code_unknown_language() {
let result = highlight_code("some text", Some("brainfuck"));
assert!(!result.is_empty());
}
#[test]
fn highlight_code_none_language() {
let result = highlight_code("plain text", None);
assert!(!result.is_empty());
}
#[test]
fn highlight_code_empty() {
let result = highlight_code("", None);
assert!(result.is_empty());
}
#[test]
fn highlight_code_produces_span_tags() {
let result = highlight_code("let x = 1;", Some("rust"));
assert!(result.contains('<'));
}
}

View File

@ -96,3 +96,96 @@ pub struct PostStats {
pub drafts: i64,
pub published: i64,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn sample_post() -> Post {
Post {
id: 1,
author_id: 1,
title: "Test".to_string(),
slug: "test".to_string(),
summary: None,
content_md: "content".to_string(),
content_html: None,
status: PostStatus::Draft,
published_at: None,
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
tags: vec![],
cover_image: None,
reading_time: 1,
word_count: 10,
toc_html: None,
prev_post: None,
next_post: None,
}
}
#[test]
fn post_status_from_str() {
assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
assert_eq!(PostStatus::from_str("published"), Some(PostStatus::Published));
assert_eq!(PostStatus::from_str("unknown"), None);
assert_eq!(PostStatus::from_str(""), None);
}
#[test]
fn post_status_as_str() {
assert_eq!(PostStatus::Draft.as_str(), "draft");
assert_eq!(PostStatus::Published.as_str(), "published");
}
#[test]
fn post_status_roundtrip() {
for status in [PostStatus::Draft, PostStatus::Published] {
assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
}
}
#[test]
fn formatted_date_uses_published_at_when_available() {
let mut post = sample_post();
post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
assert_eq!(post.formatted_date(), "2024-06-01");
}
#[test]
fn formatted_date_falls_back_to_created_at() {
let post = sample_post();
assert_eq!(post.formatted_date(), "2024-01-15");
}
#[test]
fn status_label() {
let mut post = sample_post();
post.status = PostStatus::Published;
assert_eq!(post.status_label(), "已发布");
post.status = PostStatus::Draft;
assert_eq!(post.status_label(), "草稿");
}
#[test]
fn status_class_returns_non_empty() {
let mut post = sample_post();
post.status = PostStatus::Published;
assert!(!post.status_class().is_empty());
post.status = PostStatus::Draft;
assert!(!post.status_class().is_empty());
}
#[test]
fn status_badge_class_returns_non_empty() {
let post = sample_post();
assert!(!post.status_badge_class().is_empty());
}
#[test]
fn post_status_serde_roundtrip() {
let json = serde_json::to_string(&PostStatus::Draft).unwrap();
assert_eq!(serde_json::from_str::<PostStatus>(&json).unwrap(), PostStatus::Draft);
}
}

View File

@ -48,3 +48,53 @@ impl From<User> for PublicUser {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn sample_user() -> User {
User {
id: 1,
username: "admin".to_string(),
email: "admin@test.com".to_string(),
password_hash: "hash".to_string(),
role: UserRole::Admin,
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
}
}
#[test]
fn user_role_from_str() {
assert_eq!(UserRole::from_str("admin"), Some(UserRole::Admin));
assert_eq!(UserRole::from_str("blocked"), Some(UserRole::Blocked));
assert_eq!(UserRole::from_str("unknown"), None);
assert_eq!(UserRole::from_str(""), None);
}
#[test]
fn user_to_public_user_conversion() {
let user = sample_user();
let public: PublicUser = user.clone().into();
assert_eq!(public.id, user.id);
assert_eq!(public.username, user.username);
assert_eq!(public.email, user.email);
assert_eq!(public.role, user.role);
assert_eq!(public.created_at, user.created_at);
}
#[test]
fn public_user_excludes_password_hash() {
let user = sample_user();
let public: PublicUser = user.into();
let json = serde_json::to_string(&public).unwrap();
assert!(!json.contains("password_hash"));
}
#[test]
fn user_role_serde_roundtrip() {
let json = serde_json::to_string(&UserRole::Admin).unwrap();
assert_eq!(serde_json::from_str::<UserRole>(&json).unwrap(), UserRole::Admin);
}
}

View File

@ -65,3 +65,101 @@ pub fn auto_summary(md: &str) -> String {
let plain = strip_markdown(md);
plain.chars().take(200).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_markdown_removes_code_blocks() {
let input = "before```code here```after";
assert_eq!(strip_markdown(input), "beforeafter");
}
#[test]
fn strip_markdown_removes_inline_code() {
assert_eq!(strip_markdown("text `code` more"), "text more");
}
#[test]
fn strip_markdown_removes_images() {
assert_eq!(strip_markdown("![alt](url)"), "");
}
#[test]
fn strip_markdown_keeps_link_text() {
assert_eq!(strip_markdown("[click me](https://example.com)"), "click me");
}
#[test]
fn strip_markdown_removes_headings() {
assert_eq!(strip_markdown("## Hello"), "Hello");
}
#[test]
fn strip_markdown_removes_bold_and_italic() {
assert_eq!(strip_markdown("**bold** *italic* __bold__ _italic_"), "bold italic bold italic");
}
#[test]
fn strip_markdown_empty_input() {
assert_eq!(strip_markdown(""), "");
}
#[test]
fn strip_markdown_mixed() {
let md = "# Title\n\nSome **bold** and `code` [link](url)\n\n![img](img.png)";
let result = strip_markdown(md);
assert!(result.contains("Title"));
assert!(result.contains("bold"));
assert!(result.contains("link"));
assert!(!result.contains("img"));
assert!(!result.contains("**"));
assert!(!result.contains("`"));
}
#[test]
fn count_words_english() {
assert_eq!(count_words("hello world"), 2);
}
#[test]
fn count_words_chinese() {
assert_eq!(count_words("你好世界"), 4);
}
#[test]
fn count_words_mixed() {
let count = count_words("Hello 你好 world 世界");
assert_eq!(count, 6);
}
#[test]
fn count_words_with_markdown() {
let count = count_words("# Hello **World**\n\nSome `code` here");
assert_eq!(count, 4);
}
#[test]
fn count_words_empty_returns_one() {
assert_eq!(count_words(""), 1);
}
#[test]
fn auto_summary_truncates_at_200_chars() {
let long_md: String = "a ".repeat(200);
let summary = auto_summary(&long_md);
assert_eq!(summary.chars().count(), 200);
}
#[test]
fn auto_summary_short_input() {
assert_eq!(auto_summary("short"), "short");
}
#[test]
fn auto_summary_strips_markdown() {
let summary = auto_summary("**bold** and `code`");
assert_eq!(summary, "bold and");
}
}