yggdrasil/src/api/slug.rs
xfy 4f368e6fb8 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.
2026-06-09 09:25:44 +08:00

159 lines
3.8 KiB
Rust

#![allow(clippy::unused_unit, deprecated, unused_imports)]
use dioxus::prelude::*;
#[cfg(feature = "server")]
pub fn slugify(title: &str) -> String {
let slug: String = title
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect();
let slug = parts.join("-");
if slug.is_empty() {
return format!("{}", chrono::Utc::now().timestamp());
}
slug.chars().take(100).collect()
}
#[cfg(feature = "server")]
pub fn is_valid_slug(slug: &str) -> bool {
if slug.is_empty() || slug.len() > 200 {
return false;
}
slug.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
#[cfg(feature = "server")]
pub async fn ensure_unique_slug(
client: &tokio_postgres::Client,
base: &str,
exclude_id: Option<i32>,
) -> Result<String, ServerFnError> {
use crate::api::utils::query_error;
let mut candidate = base.to_string();
let mut suffix = 2;
loop {
let exists = if let Some(exclude) = exclude_id {
client
.query_opt(
"SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2",
&[&candidate, &exclude],
)
.await
.map_err(query_error)?
.is_some()
} else {
client
.query_opt(
"SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL",
&[&candidate],
)
.await
.map_err(query_error)?
.is_some()
};
if !exists {
return Ok(candidate);
}
candidate = format!("{}-{}", base, suffix);
suffix += 1;
if candidate.len() > 200 {
return Err(ServerFnError::new("无法生成唯一 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"));
}
}