Skip to main content

yggdrasil/api/posts/
validation.rs

1//! 与认证方式无关的文章写入校验。
2
3use super::service::WriteError;
4
5/// 与 `migrations/002_posts.sql` 的列宽对齐;Postgres 按字符计长。
6const MAX_TITLE_CHARS: usize = 200;
7const MAX_SUMMARY_CHARS: usize = 500;
8const MAX_COVER_CHARS: usize = 500;
9/// 单篇文章标签数上限(与笔记一致)。
10const MAX_TAGS: usize = 16;
11const MAX_TAG_CHARS: usize = 50;
12
13pub(super) fn title(value: &str) -> Result<(), WriteError> {
14    if value.trim().is_empty() {
15        return Err(WriteError::Invalid("标题不能为空".into()));
16    }
17    if value.trim().chars().count() > MAX_TITLE_CHARS {
18        return Err(WriteError::Invalid("标题过长(上限 200 字符)".into()));
19    }
20    Ok(())
21}
22
23pub(super) fn content(value: &str) -> Result<(), WriteError> {
24    if value.trim().is_empty() {
25        return Err(WriteError::Invalid("内容不能为空".into()));
26    }
27    Ok(())
28}
29
30pub(super) fn summary(value: &str) -> Result<(), WriteError> {
31    if value.trim().chars().count() > MAX_SUMMARY_CHARS {
32        return Err(WriteError::Invalid("摘要过长(上限 500 字符)".into()));
33    }
34    Ok(())
35}
36
37pub(super) fn cover(value: &str) -> Result<(), WriteError> {
38    if value.trim().chars().count() > MAX_COVER_CHARS {
39        return Err(WriteError::Invalid("封面地址过长(上限 500 字符)".into()));
40    }
41    Ok(())
42}
43
44/// 标签数量与单标签长度;与笔记一样在清洗(去空白、去重)之后校验。
45pub(super) fn tags(raw: &[String]) -> Result<(), WriteError> {
46    let tags = super::helpers::clean_tags(raw);
47    if tags.len() > MAX_TAGS {
48        return Err(WriteError::Invalid("标签最多 16 个".into()));
49    }
50    if tags.iter().any(|t| t.chars().count() > MAX_TAG_CHARS) {
51        return Err(WriteError::Invalid("单个标签过长(上限 50 字符)".into()));
52    }
53    Ok(())
54}
55
56pub(super) fn slug(value: &str) -> Result<(), WriteError> {
57    if !crate::api::slug::is_valid_slug(value) {
58        return Err(WriteError::Invalid(
59            "slug 格式无效,只能包含字母、数字、连字符和下划线".into(),
60        ));
61    }
62    Ok(())
63}
64
65/// 状态只认 `draft` / `published`:拼写不对必须报错,不能当作草稿把已发布文章悄悄下线。
66pub(super) fn status(value: &str) -> Result<(), WriteError> {
67    if crate::models::post::PostStatus::from_str(value).is_none() {
68        return Err(WriteError::Invalid(
69            "status 无效,只能是 draft 或 published".into(),
70        ));
71    }
72    Ok(())
73}
74
75pub(super) fn base_slug(title_value: &str, supplied: Option<&str>) -> Result<String, WriteError> {
76    match supplied.map(str::trim).filter(|s| !s.is_empty()) {
77        Some(s) => {
78            slug(s)?;
79            Ok(s.into())
80        }
81        None => Ok(crate::api::slug::slugify(title_value)),
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn rejects_blank_title_and_content_before_database_access() {
91        assert!(title(" \t\n").is_err());
92        assert!(content(" \t\n").is_err());
93        assert!(title("文章").is_ok());
94        assert!(content("正文").is_ok());
95    }
96
97    #[test]
98    fn slug_is_validated_or_generated_from_title() {
99        assert_eq!(
100            base_slug("标题", Some(" custom-slug ")).unwrap(),
101            "custom-slug"
102        );
103        assert_eq!(base_slug("标题", None).unwrap(), "biao-ti");
104        assert!(base_slug("标题", Some("bad/slug")).is_err());
105    }
106
107    #[test]
108    fn rejects_overlong_fields_and_too_many_tags() {
109        assert!(title(&"字".repeat(200)).is_ok());
110        assert!(title(&"字".repeat(201)).is_err());
111        assert!(summary(&"字".repeat(500)).is_ok());
112        assert!(summary(&"字".repeat(501)).is_err());
113        assert!(cover(&"a".repeat(500)).is_ok());
114        assert!(cover(&"a".repeat(501)).is_err());
115
116        let sixteen: Vec<String> = (0..16).map(|i| format!("t{i}")).collect();
117        let seventeen: Vec<String> = (0..17).map(|i| format!("t{i}")).collect();
118        assert!(tags(&sixteen).is_ok());
119        assert!(tags(&seventeen).is_err());
120
121        assert!(tags(&["字".repeat(50)]).is_ok());
122        assert!(tags(&["字".repeat(51)]).is_err());
123
124        // 清洗(去空白、大小写去重)后只剩 1 个,不应被数量上限误伤。
125        let mut dup: Vec<String> = (0..15).map(|i| format!("t{i}")).collect();
126        dup.extend([" a ".to_owned(), "A".to_owned()]);
127        assert!(tags(&dup).is_ok());
128    }
129
130    #[test]
131    fn status_rejects_unknown_values_instead_of_defaulting_to_draft() {
132        assert!(status("draft").is_ok());
133        assert!(status("published").is_ok());
134        for bad in ["Published", "PUBLISHED", " published", "public", ""] {
135            assert!(status(bad).is_err(), "{bad:?}");
136        }
137    }
138}