diff --git a/Cargo.lock b/Cargo.lock index e9acd07..7147b90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3752,6 +3752,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5298,6 +5323,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serial_test", "sha2 0.10.9", "syntect", "tokio", diff --git a/src/api/comments/helpers.rs b/src/api/comments/helpers.rs index 78e8fe0..c27aa5b 100644 --- a/src/api/comments/helpers.rs +++ b/src/api/comments/helpers.rs @@ -98,13 +98,15 @@ pub fn validate_comment_email(email: &str) -> Result<(), String> { #[allow(dead_code)] pub fn validate_comment_url(url: &str) -> Result<(), String> { - if url.trim().is_empty() { + let trimmed = url.trim(); + if trimmed.is_empty() { return Ok(()); } - if !url.starts_with("http://") && !url.starts_with("https://") { + let lower = trimmed.to_ascii_lowercase(); + if !lower.starts_with("http://") && !lower.starts_with("https://") { return Err("网址必须以 http:// 或 https:// 开头".to_string()); } - if url.len() > 200 { + if trimmed.len() > 200 { return Err("网址长度不能超过 200 个字符".to_string()); } Ok(()) @@ -199,6 +201,24 @@ mod tests { assert_eq!(format_relative_time(dt), "7 天前"); } + #[test] + fn format_relative_time_one_minute() { + let dt = chrono::Utc::now() - chrono::Duration::minutes(1); + assert_eq!(format_relative_time(dt), "1 分钟前"); + } + + #[test] + fn format_relative_time_one_hour() { + let dt = chrono::Utc::now() - chrono::Duration::hours(1); + assert_eq!(format_relative_time(dt), "1 小时前"); + } + + #[test] + fn format_relative_time_one_day() { + let dt = chrono::Utc::now() - chrono::Duration::days(1); + assert_eq!(format_relative_time(dt), "1 天前"); + } + #[test] fn format_relative_time_old_date() { let dt = chrono::Utc::now() - chrono::Duration::days(60); @@ -260,6 +280,24 @@ mod tests { assert!(validate_comment_url("javascript:alert(1)").is_err()); } + #[test] + fn validate_comment_url_uppercase_scheme() { + assert!(validate_comment_url("HTTP://example.com").is_ok()); + assert!(validate_comment_url("HTTPS://example.com").is_ok()); + assert!(validate_comment_url("Http://example.com").is_ok()); + } + + #[test] + fn validate_comment_url_fragment() { + assert!(validate_comment_url("https://example.com#section").is_ok()); + } + + #[test] + fn validate_comment_url_relative_path_rejected() { + assert!(validate_comment_url("/path/to/page").is_err()); + assert!(validate_comment_url("path/to/page").is_err()); + } + #[test] fn validate_comment_url_too_long() { let long_url = format!("https://example.com/{}", "a".repeat(200)); diff --git a/src/api/comments/markdown.rs b/src/api/comments/markdown.rs index 5a571e2..f391246 100644 --- a/src/api/comments/markdown.rs +++ b/src/api/comments/markdown.rs @@ -143,6 +143,34 @@ mod tests { assert!(result.contains("noopener")); } + #[test] + fn render_comment_link_javascript_removed() { + let result = render_comment_markdown("[click](javascript:alert(1))"); + assert!(result.contains("click")); + assert!(!result.contains("javascript:")); + } + + #[test] + fn render_comment_onerror_attribute_removed() { + let result = render_comment_markdown("
");
+ html.push_str("');
html.push_str(&highlighted);
html.push_str("
");
in_codeblock = false;
@@ -358,8 +362,26 @@ mod tests {
#[test]
fn render_markdown_code_block() {
let result = render_markdown_enhanced("```rust\nfn main() {}\n```");
+ assert!(result.html.contains(r#""#));
+ assert!(result.html.contains(r#"main"#));
+ assert!(result.html.contains(r#"fn"#));
+ }
+
+ #[test]
+ fn render_markdown_code_block_without_language() {
+ let result = render_markdown_enhanced("```\nplain text\n```");
assert!(result.html.contains(""));
- assert!(result.html.contains("main"));
+ assert!(!result.html.contains("class=\"language-"));
+ assert!(result.html.contains("plain text"));
+ }
+
+ #[test]
+ fn render_markdown_code_block_preserves_html_content() {
+ let result = render_markdown_enhanced("```html\n\n```");
+ assert!(result.html.contains(""));
+ assert!(!result.html.contains("ok
"),
+ "ok
"
+ );
+ }
+
+ #[test]
+ fn id_and_class_preserved() {
+ assert_eq!(
+ clean_html("x
"),
+ "x
"
+ );
+ assert_eq!(
+ clean_html("x
"),
+ "x
"
+ );
+ }
+
+ #[test]
+ fn javascript_url_stripped() {
+ assert_eq!(
+ clean_html("x"),
+ "x"
+ );
+ }
+
+ #[test]
+ fn vbscript_url_stripped() {
+ assert_eq!(
+ clean_html("x"),
+ "x"
+ );
+ }
+
+ #[test]
+ fn unknown_tags_removed_content_kept() {
+ assert_eq!(clean_html("keep me "), "keep me");
+ }
+
+ #[test]
+ fn comment_removes_img_details_summary() {
+ assert_eq!(
+ clean_comment_html("
sum
body"),
+ "sumbody"
+ );
+ }
+
+ #[test]
+ fn comment_removes_data_uris() {
+ assert_eq!(
+ clean_comment_html("x"),
+ "x"
+ );
+ }
+}
diff --git a/src/api/slug.rs b/src/api/slug.rs
index 1ff825c..8f88808 100644
--- a/src/api/slug.rs
+++ b/src/api/slug.rs
@@ -155,4 +155,36 @@ mod tests {
fn is_valid_slug_accepts_chinese() {
assert!(is_valid_slug("你好-world"));
}
+
+ #[test]
+ fn slugify_all_special_characters_returns_timestamp() {
+ let slug = slugify("!@#$%^&*()+=[]{}|\\;:'\",.<>/?`~");
+ let _: i64 = slug.parse().expect("should be a valid timestamp");
+ }
+
+ #[test]
+ fn slugify_only_whitespace_returns_timestamp() {
+ let slug = slugify(" \t\n ");
+ let _: i64 = slug.parse().expect("should be a valid timestamp");
+ }
+
+ #[test]
+ fn slugify_leading_and_trailing_dashes() {
+ assert_eq!(slugify("-hello-world-"), "hello-world");
+ assert_eq!(slugify("---hello---world---"), "hello-world");
+ }
+
+ #[test]
+ fn is_valid_slug_mixed_chinese_and_digits() {
+ assert!(is_valid_slug("你好123"));
+ assert!(is_valid_slug("123你好456"));
+ }
+
+ #[test]
+ fn is_valid_slug_exact_200_char_boundary() {
+ let slug = "a".repeat(200);
+ assert!(is_valid_slug(&slug));
+ let slug = "a".repeat(201);
+ assert!(!is_valid_slug(&slug));
+ }
}
diff --git a/src/api/upload.rs b/src/api/upload.rs
index a2d9995..e14476e 100644
--- a/src/api/upload.rs
+++ b/src/api/upload.rs
@@ -298,4 +298,30 @@ mod tests {
let loaded = crate::webp::decode(&webp_bytes);
assert!(loaded.is_ok());
}
+
+ #[test]
+ fn mime_to_ext_maps_jpeg() {
+ assert_eq!(super::mime_to_ext("image/jpeg"), "jpg");
+ }
+
+ #[test]
+ fn mime_to_ext_maps_png() {
+ assert_eq!(super::mime_to_ext("image/png"), "png");
+ }
+
+ #[test]
+ fn mime_to_ext_maps_gif() {
+ assert_eq!(super::mime_to_ext("image/gif"), "gif");
+ }
+
+ #[test]
+ fn mime_to_ext_maps_webp() {
+ assert_eq!(super::mime_to_ext("image/webp"), "webp");
+ }
+
+ #[test]
+ fn mime_to_ext_falls_back_for_unknown_mime() {
+ assert_eq!(super::mime_to_ext("image/avif"), "bin");
+ assert_eq!(super::mime_to_ext("application/octet-stream"), "bin");
+ }
}
diff --git a/src/highlight.rs b/src/highlight.rs
index f917b03..e83fcc2 100644
--- a/src/highlight.rs
+++ b/src/highlight.rs
@@ -92,33 +92,33 @@ mod tests {
#[test]
fn highlight_code_rust() {
let result = highlight_code("fn main() {}", Some("rust"));
- assert!(!result.is_empty());
- assert!(result.contains("main"));
+ assert!(result.contains(r#"fn"#));
+ assert!(result.contains(r#"main"#));
}
#[test]
fn highlight_code_javascript_alias() {
let result = highlight_code("console.log('hi')", Some("js"));
- assert!(!result.is_empty());
- assert!(result.contains("console"));
+ assert!(result.contains(r#"console"#));
+ assert!(result.contains(r#"log"#));
}
#[test]
fn highlight_code_python_alias() {
let result = highlight_code("print('hi')", Some("python"));
- assert!(!result.is_empty());
+ assert!(result.contains(r#"print"#));
}
#[test]
fn highlight_code_unknown_language() {
let result = highlight_code("some text", Some("brainfuck"));
- assert!(!result.is_empty());
+ assert!(result.contains(r#"some text"#));
}
#[test]
fn highlight_code_none_language() {
let result = highlight_code("plain text", None);
- assert!(!result.is_empty());
+ assert!(result.contains(r#"plain text"#));
}
#[test]
@@ -130,6 +130,7 @@ mod tests {
#[test]
fn highlight_code_produces_span_tags() {
let result = highlight_code("let x = 1;", Some("rust"));
- assert!(result.contains('<'));
+ assert!(result.contains(r#"let"#));
+ assert!(result.contains(r#"1"#));
}
}
diff --git a/src/models/post.rs b/src/models/post.rs
index c450ad2..3d3b4f5 100644
--- a/src/models/post.rs
+++ b/src/models/post.rs
@@ -177,15 +177,24 @@ mod tests {
fn status_class_returns_non_empty() {
let mut post = sample_post();
post.status = PostStatus::Published;
- assert!(!post.status_class().is_empty());
+ assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
post.status = PostStatus::Draft;
- assert!(!post.status_class().is_empty());
+ assert_eq!(post.status_class(), "text-gray-400 dark:text-[#9b9c9d]");
}
#[test]
fn status_badge_class_returns_non_empty() {
- let post = sample_post();
- assert!(!post.status_badge_class().is_empty());
+ let mut post = sample_post();
+ post.status = PostStatus::Published;
+ assert_eq!(
+ post.status_badge_class(),
+ "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
+ );
+ post.status = PostStatus::Draft;
+ assert_eq!(
+ post.status_badge_class(),
+ "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]"
+ );
}
#[test]
diff --git a/src/theme.rs b/src/theme.rs
index 74b3f1f..873d7f1 100644
--- a/src/theme.rs
+++ b/src/theme.rs
@@ -3,7 +3,7 @@ use dioxus::prelude::*;
#[allow(dead_code)]
const THEME_KEY: &str = "yggdrasil-theme";
-#[derive(Clone, Copy, PartialEq)]
+#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Theme {
Light,
Dark,
@@ -163,3 +163,18 @@ pub fn ThemeToggle() -> Element {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn toggle_switches_light_to_dark() {
+ assert_eq!(Theme::Light.toggle(), Theme::Dark);
+ }
+
+ #[test]
+ fn toggle_switches_dark_to_light() {
+ assert_eq!(Theme::Dark.toggle(), Theme::Light);
+ }
+}
diff --git a/src/utils/text.rs b/src/utils/text.rs
index 243df5b..21f4278 100644
--- a/src/utils/text.rs
+++ b/src/utils/text.rs
@@ -109,12 +109,7 @@ mod tests {
fn strip_markdown_mixed() {
let md = "# Title\n\nSome **bold** and `code` [link](url)\n\n";
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("`"));
+ assert_eq!(result, "Title Some bold and link");
}
#[test]
diff --git a/src/webp.rs b/src/webp.rs
index ca6d192..b16de05 100644
--- a/src/webp.rs
+++ b/src/webp.rs
@@ -174,10 +174,7 @@ mod tests {
}
#[test]
- fn config_parses_valid_env_vars() {
- // Note: LazyLock initializes once, so we can't easily test
- // different env values in the same process.
- // Test that the default config is reasonable
+ fn config_default_values_are_reasonable() {
let config = WebpConfig {
quality: 85.0,
method: 2,
@@ -188,7 +185,6 @@ mod tests {
#[test]
fn config_clamping_logic() {
- // Test the clamping logic independently
let quality = 150.0f32;
let clamped = quality.clamp(0.0, 100.0);
assert_eq!(clamped, 100.0);
@@ -201,4 +197,12 @@ mod tests {
let clamped = method.clamp(0, 6);
assert_eq!(clamped, 6);
}
+
+ #[test]
+ fn config_clamps_edge_cases() {
+ assert_eq!(0.0f32.clamp(0.0, 100.0), 0.0);
+ assert_eq!(100.0f32.clamp(0.0, 100.0), 100.0);
+ assert_eq!(0u8.clamp(0, 6), 0);
+ assert_eq!(6u8.clamp(0, 6), 6);
+ }
}