1#[cfg(feature = "server")]
8use moka::future::Cache;
9#[cfg(feature = "server")]
10use std::sync::LazyLock;
11#[cfg(feature = "server")]
12use std::time::Duration;
13
14#[cfg(feature = "server")]
15use crate::models::comment::PublicComment;
16#[cfg(feature = "server")]
17use crate::models::friend_link::FriendLink;
18#[cfg(feature = "server")]
19use crate::models::post::{FeedItem, Post, PostListItem, PostStats, Tag};
20#[cfg(feature = "server")]
21use crate::models::user::SessionUser;
22
23#[cfg(feature = "server")]
29const TTL_POST_LIST: Duration = Duration::from_secs(60);
30
31#[cfg(feature = "server")]
33const TTL_TAG_LIST: Duration = Duration::from_secs(300);
34
35#[cfg(feature = "server")]
37const TTL_SINGLE_POST: Duration = Duration::from_secs(600);
38
39#[cfg(feature = "server")]
41const TTL_POST_STATS: Duration = Duration::from_secs(60);
42
43#[cfg(feature = "server")]
45const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
46
47#[cfg(feature = "server")]
49const TTL_COMMENTS: Duration = Duration::from_secs(60);
50
51#[cfg(feature = "server")]
53const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
54
55#[cfg(feature = "server")]
57const TTL_SESSION: Duration = Duration::from_secs(300);
58
59#[cfg(feature = "server")]
61const TTL_SEARCH: Duration = Duration::from_secs(10);
62
63#[cfg(feature = "server")]
69#[derive(Debug, Clone, Hash, Eq, PartialEq)]
70pub enum CacheKey {
71 PublishedPosts { page: i32, per_page: i32 },
73 TotalPublishedPosts,
75 AllTags,
77 PostBySlug(String),
79 PostsByTag(String),
81 PostStats,
83 CommentsByPost { post_id: i32 },
85 PendingCommentCount,
87 Feed,
89 FriendLinks,
91}
92
93#[cfg(feature = "server")]
99pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
100
101#[cfg(feature = "server")]
103pub type TagListCache = Cache<CacheKey, Vec<Tag>>;
104
105#[cfg(feature = "server")]
107pub type SinglePostCache = Cache<CacheKey, Option<Post>>;
108
109#[cfg(feature = "server")]
111pub type PostStatsCache = Cache<CacheKey, PostStats>;
112
113#[cfg(feature = "server")]
115pub type FeedCache = Cache<CacheKey, Vec<FeedItem>>;
116
117#[cfg(feature = "server")]
119static POST_LIST_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
120 Cache::builder()
121 .max_capacity(100)
122 .time_to_live(TTL_POST_LIST)
123 .build()
124});
125
126#[cfg(feature = "server")]
128static TAG_LIST_CACHE: LazyLock<TagListCache> = LazyLock::new(|| {
129 Cache::builder()
130 .max_capacity(50)
131 .time_to_live(TTL_TAG_LIST)
132 .build()
133});
134
135#[cfg(feature = "server")]
137pub type FriendLinksCache = Cache<CacheKey, Vec<FriendLink>>;
138
139#[cfg(feature = "server")]
141static FRIEND_LINKS_CACHE: LazyLock<FriendLinksCache> = LazyLock::new(|| {
142 Cache::builder()
143 .max_capacity(50)
144 .time_to_live(TTL_TAG_LIST)
145 .build()
146});
147
148#[cfg(feature = "server")]
150static SINGLE_POST_CACHE: LazyLock<SinglePostCache> = LazyLock::new(|| {
151 Cache::builder()
152 .max_capacity(200)
153 .time_to_live(TTL_SINGLE_POST)
154 .build()
155});
156
157#[cfg(feature = "server")]
159static POST_STATS_CACHE: LazyLock<PostStatsCache> = LazyLock::new(|| {
160 Cache::builder()
161 .max_capacity(10)
162 .time_to_live(TTL_POST_STATS)
163 .build()
164});
165
166#[cfg(feature = "server")]
168static FEED_CACHE: LazyLock<FeedCache> = LazyLock::new(|| {
169 Cache::builder()
170 .max_capacity(10)
171 .time_to_live(TTL_SINGLE_POST)
172 .build()
173});
174
175#[cfg(feature = "server")]
177static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
178 Cache::builder()
179 .max_capacity(100)
180 .time_to_live(TTL_TAG_POSTS)
181 .build()
182});
183
184#[cfg(feature = "server")]
186pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
187
188#[cfg(feature = "server")]
190static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
191 Cache::builder()
192 .max_capacity(200)
193 .time_to_live(TTL_COMMENTS)
194 .build()
195});
196
197#[cfg(feature = "server")]
199static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
200 Cache::builder()
201 .max_capacity(10)
202 .time_to_live(TTL_PENDING_COUNT)
203 .build()
204});
205
206#[cfg(feature = "server")]
208pub type SessionCache = Cache<String, SessionUser>;
209
210#[cfg(feature = "server")]
212pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
213
214#[cfg(feature = "server")]
216pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
217 Cache::builder()
218 .max_capacity(1000)
219 .time_to_live(TTL_SESSION)
220 .build()
221});
222
223#[cfg(feature = "server")]
225static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
226 Cache::builder()
227 .max_capacity(200)
228 .time_to_live(TTL_SEARCH)
229 .build()
230});
231
232#[cfg(feature = "server")]
238pub struct CacheStats {
239 pub name: &'static str,
240 hits: std::sync::atomic::AtomicU64,
241 misses: std::sync::atomic::AtomicU64,
242}
243
244#[cfg(feature = "server")]
245impl CacheStats {
246 pub const fn new(name: &'static str) -> Self {
247 Self {
248 name,
249 hits: std::sync::atomic::AtomicU64::new(0),
250 misses: std::sync::atomic::AtomicU64::new(0),
251 }
252 }
253 fn record_hit(&self) {
254 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
255 }
256 fn record_miss(&self) {
257 self.misses
258 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
259 }
260}
261
262#[cfg(feature = "server")]
264static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表");
265#[cfg(feature = "server")]
266static TAG_STATS: CacheStats = CacheStats::new("标签");
267#[cfg(feature = "server")]
268static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章");
269#[cfg(feature = "server")]
270static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计");
271#[cfg(feature = "server")]
272static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章");
273#[cfg(feature = "server")]
274static COMMENT_STATS: CacheStats = CacheStats::new("评论");
275#[cfg(feature = "server")]
276static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数");
277#[cfg(feature = "server")]
278static SESSION_STATS: CacheStats = CacheStats::new("会话用户");
279#[cfg(feature = "server")]
280static SEARCH_STATS: CacheStats = CacheStats::new("搜索");
281#[cfg(feature = "server")]
282static FEED_STATS: CacheStats = CacheStats::new("Feed");
283#[cfg(feature = "server")]
284static FRIEND_STATS: CacheStats = CacheStats::new("友链");
285
286#[cfg(feature = "server")]
288#[derive(Debug)]
289pub struct CacheStatSnapshot {
290 pub name: &'static str,
291 pub entry_count: u64,
292 pub hits: u64,
293 pub misses: u64,
294 pub hit_rate: f64,
295}
296
297#[cfg(feature = "server")]
299pub fn cache_stats() -> Vec<CacheStatSnapshot> {
300 fn snap(stats: &CacheStats, entry_count: u64) -> CacheStatSnapshot {
301 let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
302 let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
303 let total = hits + misses;
304 let hit_rate = if total == 0 {
305 0.0
306 } else {
307 hits as f64 / total as f64
308 };
309 CacheStatSnapshot {
310 name: stats.name,
311 entry_count,
312 hits,
313 misses,
314 hit_rate,
315 }
316 }
317 vec![
318 snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()),
319 snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()),
320 snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()),
321 snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()),
322 snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()),
323 snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()),
324 snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()),
325 snap(&SESSION_STATS, SESSION_CACHE.entry_count()),
326 snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()),
327 snap(&FEED_STATS, FEED_CACHE.entry_count()),
328 snap(&FRIEND_STATS, FRIEND_LINKS_CACHE.entry_count()),
329 ]
330}
331
332#[cfg(feature = "server")]
338pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
339 let v = POST_LIST_CACHE.get(key).await;
340 if v.is_some() {
341 POST_LIST_STATS.record_hit();
342 } else {
343 POST_LIST_STATS.record_miss();
344 }
345 v
346}
347
348#[cfg(feature = "server")]
350pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
351 let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await;
352}
353
354#[cfg(feature = "server")]
356pub async fn get_total_published_posts() -> Option<i64> {
357 let v = POST_LIST_CACHE
358 .get(&CacheKey::TotalPublishedPosts)
359 .await
360 .map(|(_, total)| total);
361 if v.is_some() {
362 POST_LIST_STATS.record_hit();
363 } else {
364 POST_LIST_STATS.record_miss();
365 }
366 v
367}
368
369#[cfg(feature = "server")]
371pub async fn set_total_published_posts(total: i64) {
372 let _ = POST_LIST_CACHE
373 .insert(CacheKey::TotalPublishedPosts, (vec![], total))
374 .await;
375}
376
377#[cfg(feature = "server")]
379pub async fn get_tag_list() -> Option<Vec<Tag>> {
380 let v = TAG_LIST_CACHE.get(&CacheKey::AllTags).await;
381 if v.is_some() {
382 TAG_STATS.record_hit();
383 } else {
384 TAG_STATS.record_miss();
385 }
386 v
387}
388
389#[cfg(feature = "server")]
391pub async fn set_tag_list(tags: Vec<Tag>) {
392 let _ = TAG_LIST_CACHE.insert(CacheKey::AllTags, tags).await;
393}
394
395#[cfg(feature = "server")]
397pub async fn get_friend_links() -> Option<Vec<FriendLink>> {
398 let v = FRIEND_LINKS_CACHE.get(&CacheKey::FriendLinks).await;
399 if v.is_some() {
400 FRIEND_STATS.record_hit();
401 } else {
402 FRIEND_STATS.record_miss();
403 }
404 v
405}
406
407#[cfg(feature = "server")]
409pub async fn set_friend_links(links: Vec<FriendLink>) {
410 let _ = FRIEND_LINKS_CACHE
411 .insert(CacheKey::FriendLinks, links)
412 .await;
413}
414
415#[cfg(feature = "server")]
417pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
418 let v = SINGLE_POST_CACHE
419 .get(&CacheKey::PostBySlug(slug.to_string()))
420 .await;
421 if v.is_some() {
422 SINGLE_POST_STATS.record_hit();
423 } else {
424 SINGLE_POST_STATS.record_miss();
425 }
426 v
427}
428
429#[cfg(feature = "server")]
431pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
432 let _ = SINGLE_POST_CACHE
433 .insert(CacheKey::PostBySlug(slug.to_string()), post)
434 .await;
435}
436
437#[cfg(feature = "server")]
439pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
440 let v = TAG_POSTS_CACHE
441 .get(&CacheKey::PostsByTag(tag.to_string()))
442 .await;
443 if v.is_some() {
444 TAG_POSTS_STATS.record_hit();
445 } else {
446 TAG_POSTS_STATS.record_miss();
447 }
448 v
449}
450
451#[cfg(feature = "server")]
453pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
454 let _ = TAG_POSTS_CACHE
455 .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
456 .await;
457}
458
459#[cfg(feature = "server")]
461pub async fn get_post_stats() -> Option<PostStats> {
462 let v = POST_STATS_CACHE.get(&CacheKey::PostStats).await;
463 if v.is_some() {
464 POST_STATS_STATS.record_hit();
465 } else {
466 POST_STATS_STATS.record_miss();
467 }
468 v
469}
470
471#[cfg(feature = "server")]
473pub async fn set_post_stats(stats: PostStats) {
474 let _ = POST_STATS_CACHE.insert(CacheKey::PostStats, stats).await;
475}
476
477#[cfg(feature = "server")]
479pub async fn get_feed() -> Option<Vec<FeedItem>> {
480 let v = FEED_CACHE.get(&CacheKey::Feed).await;
481 if v.is_some() {
482 FEED_STATS.record_hit();
483 } else {
484 FEED_STATS.record_miss();
485 }
486 v
487}
488
489#[cfg(feature = "server")]
491pub async fn set_feed(items: Vec<FeedItem>) {
492 let _ = FEED_CACHE.insert(CacheKey::Feed, items).await;
493}
494
495#[cfg(feature = "server")]
501pub fn invalidate_post_lists() {
502 POST_LIST_CACHE.invalidate_all();
503}
504
505#[cfg(feature = "server")]
507pub fn invalidate_all_tags() {
508 TAG_LIST_CACHE.invalidate_all();
509}
510
511#[cfg(feature = "server")]
513pub fn invalidate_friend_links() {
514 FRIEND_LINKS_CACHE.invalidate_all();
515}
516
517#[cfg(feature = "server")]
519pub async fn invalidate_post_by_slug(slug: &str) {
520 SINGLE_POST_CACHE
521 .invalidate(&CacheKey::PostBySlug(slug.to_string()))
522 .await;
523}
524
525#[cfg(feature = "server")]
527pub async fn invalidate_posts_by_tag(tag: &str) {
528 TAG_POSTS_CACHE
529 .invalidate(&CacheKey::PostsByTag(tag.to_string()))
530 .await;
531}
532
533#[cfg(feature = "server")]
535pub fn invalidate_post_stats() {
536 POST_STATS_CACHE.invalidate_all();
537}
538
539#[cfg(feature = "server")]
541pub async fn invalidate_tag_posts_for(tags: &[String]) {
542 let futures: Vec<_> = tags
543 .iter()
544 .map(|tag| invalidate_posts_by_tag(tag))
545 .collect();
546 let _ = futures::future::join_all(futures).await;
547}
548
549#[cfg(feature = "server")]
556pub fn invalidate_all_post_caches() {
557 POST_LIST_CACHE.invalidate_all();
558 TAG_LIST_CACHE.invalidate_all();
559 SINGLE_POST_CACHE.invalidate_all();
560 POST_STATS_CACHE.invalidate_all();
561 TAG_POSTS_CACHE.invalidate_all();
562 invalidate_feed();
563}
564
565#[cfg(feature = "server")]
570pub fn invalidate_feed() {
571 FEED_CACHE.invalidate_all();
572}
573
574#[cfg(feature = "server")]
580pub fn invalidate_post_metadata() {
581 invalidate_post_lists();
582 invalidate_all_tags();
583 invalidate_post_stats();
584 invalidate_search_results();
585 invalidate_feed();
586}
587
588#[cfg(feature = "server")]
602pub async fn invalidate_for_post_write(slugs: &[String], tags: &[String]) {
603 invalidate_post_metadata();
604 for slug in slugs {
605 invalidate_post_by_slug(slug).await;
606 }
607 invalidate_tag_posts_for(tags).await;
608 for slug in slugs {
609 crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
610 }
611 crate::ssr_cache::invalidate_ssr_all_public();
612 crate::ssr_cache::bump_global_generation();
613}
614
615#[cfg(feature = "server")]
617pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
618 let v = COMMENT_CACHE
619 .get(&CacheKey::CommentsByPost { post_id })
620 .await;
621 if v.is_some() {
622 COMMENT_STATS.record_hit();
623 } else {
624 COMMENT_STATS.record_miss();
625 }
626 v
627}
628
629#[cfg(feature = "server")]
631pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
632 let _ = COMMENT_CACHE
633 .insert(CacheKey::CommentsByPost { post_id }, comments)
634 .await;
635}
636
637#[cfg(feature = "server")]
639pub async fn get_pending_count() -> Option<i64> {
640 let v = PENDING_COUNT_CACHE
641 .get(&CacheKey::PendingCommentCount)
642 .await;
643 if v.is_some() {
644 PENDING_COUNT_STATS.record_hit();
645 } else {
646 PENDING_COUNT_STATS.record_miss();
647 }
648 v
649}
650
651#[cfg(feature = "server")]
653pub async fn set_pending_count(count: i64) {
654 let _ = PENDING_COUNT_CACHE
655 .insert(CacheKey::PendingCommentCount, count)
656 .await;
657}
658
659#[cfg(feature = "server")]
661pub fn normalize_search_key(query: &str) -> String {
662 query.trim().to_lowercase().chars().take(200).collect()
663}
664
665#[cfg(feature = "server")]
667pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
668 let v = SESSION_CACHE.get(token_hash).await;
669 if v.is_some() {
670 SESSION_STATS.record_hit();
671 } else {
672 SESSION_STATS.record_miss();
673 }
674 v
675}
676
677#[cfg(feature = "server")]
679pub async fn set_session_user(token_hash: &str, user: SessionUser) {
680 let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
681}
682
683#[cfg(feature = "server")]
685pub async fn invalidate_session_user(token_hash: &str) {
686 SESSION_CACHE.invalidate(token_hash).await;
687}
688
689#[cfg(feature = "server")]
691pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
692 let v = SEARCH_CACHE.get(&normalize_search_key(query)).await;
693 if v.is_some() {
694 SEARCH_STATS.record_hit();
695 } else {
696 SEARCH_STATS.record_miss();
697 }
698 v
699}
700
701#[cfg(feature = "server")]
703pub async fn set_search_results(query: &str, posts: Vec<PostListItem>, total: i64) {
704 let _ = SEARCH_CACHE
705 .insert(normalize_search_key(query), (posts, total))
706 .await;
707}
708
709#[cfg(feature = "server")]
714pub fn invalidate_search_results() {
715 SEARCH_CACHE.invalidate_all();
716}
717
718#[cfg(feature = "server")]
720pub async fn invalidate_comments_by_post(post_id: i32) {
721 COMMENT_CACHE
722 .invalidate(&CacheKey::CommentsByPost { post_id })
723 .await;
724}
725
726#[cfg(feature = "server")]
728pub async fn invalidate_pending_count() {
729 PENDING_COUNT_CACHE
730 .invalidate(&CacheKey::PendingCommentCount)
731 .await;
732}
733
734#[cfg(feature = "server")]
739pub fn invalidate_all_comments() {
740 COMMENT_CACHE.invalidate_all();
741 PENDING_COUNT_CACHE.invalidate_all();
742}
743
744#[cfg(all(test, feature = "server"))]
745mod tests {
746 use super::*;
747 use crate::models::comment::PublicComment;
748 use crate::models::post::PostStatus;
749 use crate::models::user::{SessionUser, UserRole};
750 use serial_test::serial;
751
752 #[test]
753 #[serial]
754 fn cache_key_equality() {
755 let k1 = CacheKey::PublishedPosts {
756 page: 1,
757 per_page: 10,
758 };
759 let k2 = CacheKey::PublishedPosts {
760 page: 1,
761 per_page: 10,
762 };
763 let k3 = CacheKey::PublishedPosts {
764 page: 2,
765 per_page: 10,
766 };
767 assert_eq!(k1, k2);
768 assert_ne!(k1, k3);
769 }
770
771 #[tokio::test]
772 #[serial]
773 async fn post_list_cache_roundtrip() {
774 let key = CacheKey::PublishedPosts {
775 page: 999,
776 per_page: 99,
777 };
778 let posts = vec![PostListItem {
779 id: 1,
780 author_id: 1,
781 title: "List Item".to_string(),
782 slug: "list-item".to_string(),
783 summary: None,
784 status: PostStatus::Published,
785 published_at: None,
786 created_at: chrono::Utc::now(),
787 updated_at: chrono::Utc::now(),
788 deleted_at: None,
789 tags: vec!["rust".to_string()],
790 cover_image: None,
791 reading_time: 1,
792 word_count: 10,
793 }];
794
795 set_post_list(&key, posts.clone(), 1).await;
796 let cached = get_post_list(&key).await;
797
798 assert!(cached.is_some());
799 let (cached_posts, cached_total) = cached.unwrap();
800 assert_eq!(cached_posts.len(), 1);
801 assert_eq!(cached_posts[0].title, "List Item");
802 assert_eq!(cached_total, 1);
803 }
804
805 #[tokio::test]
806 #[serial]
807 async fn tag_list_cache_roundtrip() {
808 let tags = vec![Tag {
809 id: 1,
810 name: "rust".to_string(),
811 post_count: 5,
812 }];
813
814 set_tag_list(tags.clone()).await;
815 let cached = get_tag_list().await;
816
817 assert!(cached.is_some());
818 assert_eq!(cached.unwrap()[0].name, "rust");
819 }
820
821 #[tokio::test]
822 #[serial]
823 async fn single_post_cache_roundtrip() {
824 let post = Some(Post {
825 id: 1,
826 author_id: 1,
827 title: "Test".to_string(),
828 slug: "test".to_string(),
829 summary: None,
830 content_md: "content".to_string(),
831 content_html: None,
832 status: PostStatus::Published,
833 published_at: None,
834 created_at: chrono::Utc::now(),
835 updated_at: chrono::Utc::now(),
836 deleted_at: None,
837 tags: vec![],
838 cover_image: None,
839 reading_time: 1,
840 word_count: 10,
841 toc_html: None,
842 prev_post: None,
843 next_post: None,
844 });
845
846 set_post_by_slug("test", post.clone()).await;
847 let cached = get_post_by_slug("test").await;
848
849 assert!(cached.is_some());
850 assert_eq!(cached.unwrap().unwrap().title, "Test");
851 }
852
853 #[tokio::test]
854 #[serial]
855 async fn post_stats_cache_roundtrip() {
856 let stats = PostStats {
857 total: 10,
858 drafts: 3,
859 published: 7,
860 trash: 2,
861 };
862
863 set_post_stats(stats.clone()).await;
864 let cached = get_post_stats().await;
865
866 assert!(cached.is_some());
867 assert_eq!(cached.unwrap().total, 10);
868 }
869
870 #[tokio::test]
871 #[serial]
872 async fn cache_invalidation_works() {
873 let post = Some(Post {
874 id: 42,
875 author_id: 1,
876 title: "Invalidation Test".to_string(),
877 slug: "invalidation-test".to_string(),
878 summary: None,
879 content_md: "test".to_string(),
880 content_html: None,
881 status: PostStatus::Published,
882 published_at: None,
883 created_at: chrono::Utc::now(),
884 updated_at: chrono::Utc::now(),
885 deleted_at: None,
886 tags: vec![],
887 cover_image: None,
888 reading_time: 1,
889 word_count: 4,
890 toc_html: None,
891 prev_post: None,
892 next_post: None,
893 });
894
895 set_post_by_slug("invalidation-test", post.clone()).await;
896 let cached_before = get_post_by_slug("invalidation-test").await;
897 assert!(cached_before.is_some());
898
899 invalidate_post_by_slug("invalidation-test").await;
900
901 let cached_after = get_post_by_slug("invalidation-test").await;
902 assert!(cached_after.is_none());
903 }
904
905 #[tokio::test]
906 #[serial]
907 async fn comment_cache_roundtrip() {
908 let comments = vec![PublicComment {
909 id: 1,
910 parent_id: None,
911 depth: 0,
912 author_name: "Alice".to_string(),
913 author_url: None,
914 avatar_url: "https://example.com/avatar".to_string(),
915 content_html: Some("<p>Hello</p>".to_string()),
916 created_at: "刚刚".to_string(),
917 created_at_iso: "2026-01-01T00:00:00Z".to_string(),
918 }];
919
920 set_comments_by_post(42, comments.clone()).await;
921 let cached = get_comments_by_post(42).await;
922
923 assert!(cached.is_some());
924 assert_eq!(cached.unwrap().len(), 1);
925 }
926
927 #[tokio::test]
928 #[serial]
929 async fn pending_count_cache_roundtrip() {
930 set_pending_count(7).await;
931 let cached = get_pending_count().await;
932
933 assert!(cached.is_some());
934 assert_eq!(cached.unwrap(), 7);
935 }
936
937 #[tokio::test]
938 #[serial]
939 async fn comment_cache_invalidation() {
940 set_comments_by_post(99, vec![]).await;
941 assert!(get_comments_by_post(99).await.is_some());
942
943 invalidate_comments_by_post(99).await;
944 assert!(get_comments_by_post(99).await.is_none());
945 }
946
947 #[tokio::test]
948 #[serial]
949 async fn pending_count_invalidation() {
950 set_pending_count(3).await;
951 assert!(get_pending_count().await.is_some());
952
953 invalidate_pending_count().await;
954 assert!(get_pending_count().await.is_none());
955 }
956
957 #[tokio::test]
958 #[serial]
959 async fn session_cache_roundtrip() {
960 let user = SessionUser {
961 id: 42,
962 username: "cached_user".to_string(),
963 email: "cached@example.com".to_string(),
964 role: UserRole::Admin,
965 created_at: chrono::Utc::now(),
966 session_generation: 0,
967 };
968 let token_hash = "sha256_token_hash";
969
970 set_session_user(token_hash, user.clone()).await;
971 let cached = get_session_user(token_hash).await;
972
973 assert!(cached.is_some());
974 let cached_user = cached.unwrap();
975 assert_eq!(cached_user.id, user.id);
976 assert_eq!(cached_user.username, user.username);
977 assert_eq!(cached_user.email, user.email);
978 assert_eq!(cached_user.role, user.role);
979
980 invalidate_session_user(token_hash).await;
981 assert!(get_session_user(token_hash).await.is_none());
982 }
983
984 #[test]
985 fn search_key_normalization() {
986 assert_eq!(normalize_search_key(" Rust "), "rust");
987 assert_eq!(normalize_search_key("Rust"), "rust");
988 assert_eq!(normalize_search_key(" rust "), "rust");
989 assert_eq!(normalize_search_key(""), "");
990
991 let long = "a".repeat(250);
992 let normalized = normalize_search_key(&long);
993 assert_eq!(normalized.len(), 200);
994 assert!(normalized.chars().all(|c| c == 'a'));
995
996 assert_eq!(
998 normalize_search_key(" Dioxus Fullstack "),
999 normalize_search_key("dioxus fullstack")
1000 );
1001 }
1002
1003 #[tokio::test]
1004 #[serial]
1005 async fn search_cache_roundtrip() {
1006 let query = "Rust";
1007 let posts = vec![PostListItem {
1008 id: 1,
1009 author_id: 1,
1010 title: "Search Result".to_string(),
1011 slug: "search-result".to_string(),
1012 summary: None,
1013 status: PostStatus::Published,
1014 published_at: None,
1015 created_at: chrono::Utc::now(),
1016 updated_at: chrono::Utc::now(),
1017 deleted_at: None,
1018 tags: vec!["rust".to_string()],
1019 cover_image: None,
1020 reading_time: 1,
1021 word_count: 10,
1022 }];
1023
1024 set_search_results(query, posts.clone(), 1).await;
1025
1026 let cached = get_search_results(" rust ").await;
1028 assert!(cached.is_some());
1029 let (cached_posts, cached_total) = cached.unwrap();
1030 assert_eq!(cached_posts.len(), 1);
1031 assert_eq!(cached_posts[0].title, "Search Result");
1032 assert_eq!(cached_total, 1);
1033
1034 invalidate_search_results();
1035 assert!(get_search_results(query).await.is_none());
1036 }
1037
1038 #[tokio::test]
1039 #[serial]
1040 async fn search_cache_invalidation() {
1041 set_search_results("tokio", vec![], 0).await;
1042 assert!(get_search_results("tokio").await.is_some());
1043
1044 invalidate_search_results();
1045 assert!(get_search_results("tokio").await.is_none());
1046 }
1047}