1use crate::constants;
2use crate::error::Error;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9#[derive(Debug, Clone)]
11pub struct CacheConfig {
12 pub cache_dir: PathBuf,
14 pub default_ttl: Duration,
16 pub max_entries: usize,
18 pub enabled: bool,
20 pub allow_authenticated: bool,
23}
24
25impl Default for CacheConfig {
26 fn default() -> Self {
27 Self {
28 cache_dir: PathBuf::from(".cache/responses"),
29 default_ttl: Duration::from_secs(300), max_entries: 1000,
31 enabled: true,
32 allow_authenticated: false, }
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CachedResponse {
40 pub body: String,
42 pub status_code: u16,
44 pub headers: HashMap<String, String>,
46 pub cached_at: u64,
48 pub ttl_seconds: u64,
50 pub request_info: CachedRequestInfo,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CachedRequestInfo {
57 pub method: String,
59 pub url: String,
61 pub headers: HashMap<String, String>,
63 pub body_hash: Option<String>,
65}
66
67#[derive(Debug)]
69pub struct CacheKey {
70 pub api_name: String,
72 pub operation_id: String,
74 pub request_hash: String,
76}
77
78impl CacheKey {
79 pub fn from_request(
85 api_name: &str,
86 operation_id: &str,
87 method: &str,
88 url: &str,
89 headers: &HashMap<String, String>,
90 body: Option<&str>,
91 ) -> Result<Self, Error> {
92 let mut hasher = Sha256::new();
93
94 hasher.update(method.as_bytes());
96 hasher.update(url.as_bytes());
97
98 let mut sorted_headers: Vec<_> = headers
100 .iter()
101 .filter(|(key, _)| !is_auth_header(key))
102 .collect();
103 sorted_headers.sort_by_key(|(key, _)| *key);
104
105 for (key, value) in sorted_headers {
106 hasher.update(key.as_bytes());
107 hasher.update(value.as_bytes());
108 }
109
110 if let Some(body_content) = body {
112 hasher.update(body_content.as_bytes());
113 }
114
115 let hash = hasher.finalize();
116 let request_hash = format!("{hash:x}");
117
118 Ok(Self {
119 api_name: api_name.to_string(),
120 operation_id: operation_id.to_string(),
121 request_hash,
122 })
123 }
124
125 #[must_use]
127 pub fn to_filename(&self) -> String {
128 let hash_prefix = if self.request_hash.len() >= 16 {
129 &self.request_hash[..16]
130 } else {
131 &self.request_hash
132 };
133
134 format!(
135 "{}_{}_{}_{}{}",
136 self.api_name,
137 self.operation_id,
138 hash_prefix,
139 constants::CACHE_SUFFIX,
140 constants::FILE_EXT_JSON
141 )
142 }
143}
144
145pub struct ResponseCache {
147 config: CacheConfig,
148}
149
150impl ResponseCache {
151 pub fn new(config: CacheConfig) -> Result<Self, Error> {
157 std::fs::create_dir_all(&config.cache_dir)
159 .map_err(|e| Error::io_error(format!("Failed to create cache directory: {e}")))?;
160
161 Ok(Self { config })
162 }
163
164 async fn acquire_lock(&self) -> Result<crate::atomic::DirLock, Error> {
169 let cache_dir = self.config.cache_dir.clone();
170 tokio::task::spawn_blocking(move || crate::atomic::DirLock::acquire(&cache_dir))
171 .await
172 .map_err(|e| Error::io_error(format!("Lock task failed: {e}")))?
173 .map_err(|e| Error::io_error(format!("Failed to acquire cache lock: {e}")))
174 }
175
176 pub async fn store(
185 &self,
186 key: &CacheKey,
187 body: &str,
188 status_code: u16,
189 headers: &HashMap<String, String>,
190 request_info: CachedRequestInfo,
191 ttl: Option<Duration>,
192 ) -> Result<(), Error> {
193 if !self.config.enabled {
194 return Ok(());
195 }
196
197 let now = SystemTime::now()
198 .duration_since(UNIX_EPOCH)
199 .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
200 .as_secs();
201
202 let ttl_seconds = ttl.unwrap_or(self.config.default_ttl).as_secs();
203
204 let cached_response = CachedResponse {
205 body: body.to_string(),
206 status_code,
207 headers: headers.clone(),
208 cached_at: now,
209 ttl_seconds,
210 request_info,
211 };
212
213 let cache_file = self.config.cache_dir.join(key.to_filename());
214 let json_content = serde_json::to_string_pretty(&cached_response).map_err(|e| {
215 Error::serialization_error(format!("Failed to serialize cached response: {e}"))
216 })?;
217
218 let _lock = self.acquire_lock().await?;
221
222 crate::atomic::atomic_write(&cache_file, json_content.as_bytes())
223 .await
224 .map_err(|e| Error::io_error(format!("Failed to write cache file: {e}")))?;
225
226 self.cleanup_old_entries(&key.api_name).await?;
228
229 Ok(())
231 }
232
233 pub async fn get(&self, key: &CacheKey) -> Result<Option<CachedResponse>, Error> {
241 if !self.config.enabled {
242 return Ok(None);
243 }
244
245 let cache_file = self.config.cache_dir.join(key.to_filename());
246
247 if !cache_file.exists() {
248 return Ok(None);
249 }
250
251 let json_content = tokio::fs::read_to_string(&cache_file)
252 .await
253 .map_err(|e| Error::io_error(format!("Failed to read cache file: {e}")))?;
254 let cached_response: CachedResponse = serde_json::from_str(&json_content).map_err(|e| {
255 Error::serialization_error(format!("Failed to deserialize cached response: {e}"))
256 })?;
257
258 let now = SystemTime::now()
260 .duration_since(UNIX_EPOCH)
261 .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
262 .as_secs();
263
264 if now > cached_response.cached_at + cached_response.ttl_seconds {
265 return Ok(None);
270 }
271
272 Ok(Some(cached_response))
273 }
274
275 pub async fn is_cached(&self, key: &CacheKey) -> Result<bool, Error> {
281 Ok(self.get(key).await?.is_some())
282 }
283
284 pub async fn clear_api_cache(&self, api_name: &str) -> Result<usize, Error> {
293 let _lock = self.acquire_lock().await?;
294
295 let mut cleared_count = 0;
296 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
297 .await
298 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
299
300 while let Some(entry) = entries
301 .next_entry()
302 .await
303 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
304 {
305 let filename = entry.file_name();
306 let filename_str = filename.to_string_lossy();
307
308 if filename_str.starts_with(&format!("{api_name}_"))
309 && filename_str.ends_with(constants::CACHE_FILE_SUFFIX)
310 {
311 tokio::fs::remove_file(entry.path())
312 .await
313 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
314 cleared_count += 1;
315 }
316 }
317
318 Ok(cleared_count)
319 }
320
321 pub async fn clear_all(&self) -> Result<usize, Error> {
330 let _lock = self.acquire_lock().await?;
331
332 let mut cleared_count = 0;
333 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
334 .await
335 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
336
337 while let Some(entry) = entries
338 .next_entry()
339 .await
340 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
341 {
342 let filename = entry.file_name();
343 let filename_str = filename.to_string_lossy();
344
345 if filename_str.ends_with(constants::CACHE_FILE_SUFFIX) {
346 tokio::fs::remove_file(entry.path())
347 .await
348 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
349 cleared_count += 1;
350 }
351 }
352
353 Ok(cleared_count)
354 }
355
356 pub async fn get_stats(&self, api_name: Option<&str>) -> Result<CacheStats, Error> {
362 let mut stats = CacheStats::default();
363 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
364 .await
365 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
366
367 while let Some(entry) = entries
368 .next_entry()
369 .await
370 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
371 {
372 let filename = entry.file_name();
373 let filename_str = filename.to_string_lossy();
374
375 if !filename_str.ends_with(constants::CACHE_FILE_SUFFIX) {
376 continue;
377 }
378
379 let Some(target_api) = api_name else {
381 stats.total_entries += 1;
383
384 let Ok(metadata) = entry.metadata().await else {
386 continue;
387 };
388
389 stats.total_size_bytes += metadata.len();
390
391 let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await else {
393 continue;
394 };
395
396 let Ok(cached_response) = serde_json::from_str::<CachedResponse>(&json_content)
397 else {
398 continue;
399 };
400
401 let now = SystemTime::now()
402 .duration_since(UNIX_EPOCH)
403 .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
404 .as_secs();
405
406 if now > cached_response.cached_at + cached_response.ttl_seconds {
407 stats.expired_entries += 1;
408 } else {
409 stats.valid_entries += 1;
410 }
411
412 continue;
413 };
414
415 if !filename_str.starts_with(&format!("{target_api}_")) {
416 continue;
417 }
418
419 stats.total_entries += 1;
420
421 let Ok(metadata) = entry.metadata().await else {
423 continue;
424 };
425
426 stats.total_size_bytes += metadata.len();
427
428 let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await else {
430 continue;
431 };
432
433 let Ok(cached_response) = serde_json::from_str::<CachedResponse>(&json_content) else {
434 continue;
435 };
436
437 let now = SystemTime::now()
438 .duration_since(UNIX_EPOCH)
439 .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
440 .as_secs();
441
442 if now > cached_response.cached_at + cached_response.ttl_seconds {
443 stats.expired_entries += 1;
444 } else {
445 stats.valid_entries += 1;
446 }
447 }
448
449 Ok(stats)
450 }
451
452 async fn collect_stale_temp_file(
455 &self,
456 entry: &tokio::fs::DirEntry,
457 now: SystemTime,
458 stale_files: &mut Vec<std::path::PathBuf>,
459 ) {
460 let is_stale = entry
461 .metadata()
462 .await
463 .ok()
464 .and_then(|m| m.modified().ok())
465 .is_some_and(|modified| {
466 now.duration_since(modified).unwrap_or(Duration::ZERO) > Duration::from_secs(3600)
467 });
468 if is_stale {
469 stale_files.push(entry.path());
470 }
471 }
472
473 async fn cleanup_old_entries(&self, api_name: &str) -> Result<(), Error> {
477 let mut entries = Vec::new();
478 let mut stale_tmp_files = Vec::new();
479 let now_system = SystemTime::now();
480
481 let mut dir_entries = tokio::fs::read_dir(&self.config.cache_dir)
482 .await
483 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
484
485 while let Some(entry) = dir_entries
486 .next_entry()
487 .await
488 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
489 {
490 let filename = entry.file_name();
491 let filename_str = filename.to_string_lossy();
492
493 let is_temp_file = filename_str.starts_with('.')
496 && filename_str.ends_with(".tmp")
497 && filename_str.len() > 4;
498
499 if is_temp_file {
500 self.collect_stale_temp_file(&entry, now_system, &mut stale_tmp_files)
501 .await;
502 continue;
503 }
504
505 if !filename_str.starts_with(&format!("{api_name}_"))
506 || !filename_str.ends_with(constants::CACHE_FILE_SUFFIX)
507 {
508 continue;
509 }
510
511 let Ok(metadata) = entry.metadata().await else {
512 continue;
513 };
514
515 let Ok(modified) = metadata.modified() else {
516 continue;
517 };
518
519 entries.push((entry.path(), modified));
520 }
521
522 for path in &stale_tmp_files {
524 let _ = tokio::fs::remove_file(path).await;
525 }
526
527 if entries.len() > self.config.max_entries {
529 entries.sort_by_key(|(_, modified)| *modified);
530 let to_remove = entries.len() - self.config.max_entries;
531
532 for (path, _) in entries.iter().take(to_remove) {
533 let _ = tokio::fs::remove_file(path).await;
534 }
535 }
536
537 Ok(())
538 }
539}
540
541#[derive(Debug, Default)]
543pub struct CacheStats {
544 pub total_entries: usize,
546 pub valid_entries: usize,
548 pub expired_entries: usize,
550 pub total_size_bytes: u64,
552}
553
554#[must_use]
556pub fn is_auth_header(header_name: &str) -> bool {
557 constants::is_auth_header(header_name)
558 || header_name
559 .to_lowercase()
560 .starts_with(constants::HEADER_PREFIX_X_AUTH)
561 || header_name
562 .to_lowercase()
563 .starts_with(constants::HEADER_PREFIX_X_API)
564}
565
566#[must_use]
571pub fn scrub_auth_headers<S: std::hash::BuildHasher>(
572 headers: &HashMap<String, String, S>,
573) -> HashMap<String, String> {
574 headers
575 .iter()
576 .filter(|(key, _)| !is_auth_header(key))
577 .map(|(k, v)| (k.clone(), v.clone()))
578 .collect()
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use tempfile::TempDir;
585
586 fn create_test_cache_config() -> (CacheConfig, TempDir) {
587 let temp_dir = TempDir::new().unwrap();
588 let config = CacheConfig {
589 cache_dir: temp_dir.path().to_path_buf(),
590 default_ttl: Duration::from_secs(60),
591 max_entries: 10,
592 enabled: true,
593 allow_authenticated: false,
594 };
595 (config, temp_dir)
596 }
597
598 #[test]
599 fn test_cache_key_generation() {
600 let mut headers = HashMap::new();
601 headers.insert(
602 constants::HEADER_CONTENT_TYPE_LC.to_string(),
603 constants::CONTENT_TYPE_JSON.to_string(),
604 );
605 headers.insert(
606 constants::HEADER_AUTHORIZATION_LC.to_string(),
607 "Bearer secret".to_string(),
608 ); let key = CacheKey::from_request(
611 "test_api",
612 "getUser",
613 constants::HTTP_METHOD_GET,
614 "https://api.example.com/users/123",
615 &headers,
616 None,
617 )
618 .unwrap();
619
620 assert_eq!(key.api_name, "test_api");
621 assert_eq!(key.operation_id, "getUser");
622 assert!(!key.request_hash.is_empty());
623
624 let filename = key.to_filename();
625 assert!(filename.starts_with("test_api_getUser_"));
626 assert!(filename.ends_with(constants::CACHE_FILE_SUFFIX));
627 }
628
629 #[test]
630 fn test_is_auth_header() {
631 assert!(is_auth_header(constants::HEADER_AUTHORIZATION));
632 assert!(is_auth_header("X-API-Key"));
633 assert!(is_auth_header("x-auth-token"));
634 assert!(!is_auth_header(constants::HEADER_CONTENT_TYPE));
635 assert!(!is_auth_header("User-Agent"));
636 }
637
638 #[test]
639 fn test_scrub_auth_headers() {
640 let mut headers = HashMap::new();
641 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
642 headers.insert("X-API-Key".to_string(), "api-key-123".to_string());
643 headers.insert("x-auth-token".to_string(), "token-456".to_string());
644 headers.insert("Content-Type".to_string(), "application/json".to_string());
645 headers.insert("User-Agent".to_string(), "test-agent".to_string());
646 headers.insert("Accept".to_string(), "application/json".to_string());
647
648 let scrubbed = scrub_auth_headers(&headers);
649
650 assert!(!scrubbed.contains_key("Authorization"));
652 assert!(!scrubbed.contains_key("X-API-Key"));
653 assert!(!scrubbed.contains_key("x-auth-token"));
654
655 assert_eq!(
657 scrubbed.get("Content-Type"),
658 Some(&"application/json".to_string())
659 );
660 assert_eq!(scrubbed.get("User-Agent"), Some(&"test-agent".to_string()));
661 assert_eq!(
662 scrubbed.get("Accept"),
663 Some(&"application/json".to_string())
664 );
665
666 assert_eq!(scrubbed.len(), 3);
668 }
669
670 #[tokio::test]
671 async fn test_cache_store_and_retrieve() {
672 let (config, _temp_dir) = create_test_cache_config();
673 let cache = ResponseCache::new(config).unwrap();
674
675 let key = CacheKey {
676 api_name: "test_api".to_string(),
677 operation_id: "getUser".to_string(),
678 request_hash: "abc123".to_string(),
679 };
680
681 let mut headers = HashMap::new();
682 headers.insert(
683 constants::HEADER_CONTENT_TYPE_LC.to_string(),
684 constants::CONTENT_TYPE_JSON.to_string(),
685 );
686
687 let request_info = CachedRequestInfo {
688 method: constants::HTTP_METHOD_GET.to_string(),
689 url: "https://api.example.com/users/123".to_string(),
690 headers: headers.clone(),
691 body_hash: None,
692 };
693
694 cache
696 .store(
697 &key,
698 r#"{"id": 123, "name": "John"}"#,
699 200,
700 &headers,
701 request_info,
702 Some(Duration::from_secs(60)),
703 )
704 .await
705 .unwrap();
706
707 let cached = cache.get(&key).await.unwrap();
709 assert!(cached.is_some());
710
711 let response = cached.unwrap();
712 assert_eq!(response.body, r#"{"id": 123, "name": "John"}"#);
713 assert_eq!(response.status_code, 200);
714 }
715
716 #[tokio::test]
717 async fn test_cache_expiration() {
718 let (config, _temp_dir) = create_test_cache_config();
719 let cache = ResponseCache::new(config).unwrap();
720
721 let key = CacheKey {
722 api_name: "test_api".to_string(),
723 operation_id: "getUser".to_string(),
724 request_hash: "abc123def456".to_string(),
725 };
726
727 let headers = HashMap::new();
728 let request_info = CachedRequestInfo {
729 method: constants::HTTP_METHOD_GET.to_string(),
730 url: "https://api.example.com/users/123".to_string(),
731 headers: headers.clone(),
732 body_hash: None,
733 };
734
735 cache
737 .store(
738 &key,
739 "test response",
740 200,
741 &headers,
742 request_info,
743 Some(Duration::from_secs(1)),
744 )
745 .await
746 .unwrap();
747
748 assert!(cache.is_cached(&key).await.unwrap());
750
751 let cache_file = cache.config.cache_dir.join(key.to_filename());
753 let mut cached_response: CachedResponse = {
754 let json_content = tokio::fs::read_to_string(&cache_file).await.unwrap();
755 serde_json::from_str(&json_content).unwrap()
756 };
757
758 cached_response.cached_at = SystemTime::now()
760 .duration_since(UNIX_EPOCH)
761 .unwrap()
762 .as_secs()
763 - 2; let json_content = serde_json::to_string_pretty(&cached_response).unwrap();
766 tokio::fs::write(&cache_file, json_content).await.unwrap();
767
768 assert!(!cache.is_cached(&key).await.unwrap());
770
771 assert!(cache_file.exists());
775 }
776
777 async fn store_entry(cache: &ResponseCache, api_name: &str, operation_id: &str) {
780 let key = CacheKey {
781 api_name: api_name.to_string(),
782 operation_id: operation_id.to_string(),
783 request_hash: format!("{api_name}_{operation_id}"),
784 };
785 let request_info = CachedRequestInfo {
786 method: constants::HTTP_METHOD_GET.to_string(),
787 url: "https://api.example.com/test".to_string(),
788 headers: HashMap::new(),
789 body_hash: None,
790 };
791 cache
792 .store(
793 &key,
794 r#"{"ok": true}"#,
795 200,
796 &HashMap::new(),
797 request_info,
798 Some(Duration::from_secs(300)),
799 )
800 .await
801 .unwrap();
802 }
803
804 #[tokio::test]
807 async fn test_clear_api_cache_removes_only_target_api() {
808 let (config, _temp_dir) = create_test_cache_config();
809 let cache = ResponseCache::new(config).unwrap();
810
811 store_entry(&cache, "api_a", "op1").await;
813 store_entry(&cache, "api_b", "op2").await;
814
815 let cleared = cache.clear_api_cache("api_a").await.unwrap();
816 assert_eq!(
817 cleared, 1,
818 "should have cleared exactly one entry for api_a"
819 );
820
821 let stats = cache.get_stats(Some("api_b")).await.unwrap();
823 assert_eq!(stats.total_entries, 1, "api_b entry must remain");
824
825 let stats_a = cache.get_stats(Some("api_a")).await.unwrap();
827 assert_eq!(stats_a.total_entries, 0, "api_a entries must be gone");
828 }
829
830 #[tokio::test]
831 async fn test_clear_api_cache_multiple_entries() {
832 let (config, _temp_dir) = create_test_cache_config();
833 let cache = ResponseCache::new(config).unwrap();
834
835 store_entry(&cache, "api_a", "op1").await;
836 store_entry(&cache, "api_a", "op2").await;
837 store_entry(&cache, "api_a", "op3").await;
838 store_entry(&cache, "api_b", "opX").await;
839
840 let cleared = cache.clear_api_cache("api_a").await.unwrap();
841 assert_eq!(cleared, 3, "should clear all three api_a entries");
842
843 let remaining = cache.get_stats(None).await.unwrap();
844 assert_eq!(remaining.total_entries, 1, "only api_b entry should remain");
845 }
846
847 #[tokio::test]
850 async fn test_clear_all_empties_the_cache() {
851 let (config, _temp_dir) = create_test_cache_config();
852 let cache = ResponseCache::new(config).unwrap();
853
854 store_entry(&cache, "api_a", "op1").await;
855 store_entry(&cache, "api_b", "op2").await;
856 store_entry(&cache, "api_c", "op3").await;
857
858 let cleared = cache.clear_all().await.unwrap();
859 assert_eq!(cleared, 3);
860
861 let stats = cache.get_stats(None).await.unwrap();
862 assert_eq!(
863 stats.total_entries, 0,
864 "cache must be empty after clear_all"
865 );
866 }
867
868 #[tokio::test]
869 async fn test_clear_all_on_empty_cache() {
870 let (config, _temp_dir) = create_test_cache_config();
871 let cache = ResponseCache::new(config).unwrap();
872
873 let cleared = cache.clear_all().await.unwrap();
874 assert_eq!(cleared, 0, "clearing an empty cache returns 0");
875 }
876
877 #[tokio::test]
880 async fn test_get_stats_no_filter_counts_all_entries() {
881 let (config, _temp_dir) = create_test_cache_config();
882 let cache = ResponseCache::new(config).unwrap();
883
884 store_entry(&cache, "api_a", "op1").await;
885 store_entry(&cache, "api_b", "op2").await;
886
887 let stats = cache.get_stats(None).await.unwrap();
888 assert_eq!(stats.total_entries, 2);
889 assert_eq!(stats.valid_entries, 2);
890 assert_eq!(stats.expired_entries, 0);
891 assert!(stats.total_size_bytes > 0);
892 }
893
894 #[tokio::test]
895 async fn test_get_stats_with_api_filter() {
896 let (config, _temp_dir) = create_test_cache_config();
897 let cache = ResponseCache::new(config).unwrap();
898
899 store_entry(&cache, "api_a", "op1").await;
900 store_entry(&cache, "api_a", "op2").await;
901 store_entry(&cache, "api_b", "opX").await;
902
903 let stats = cache.get_stats(Some("api_a")).await.unwrap();
904 assert_eq!(stats.total_entries, 2, "filter must restrict to api_a");
905 assert_eq!(stats.valid_entries, 2);
906
907 let stats_b = cache.get_stats(Some("api_b")).await.unwrap();
908 assert_eq!(stats_b.total_entries, 1);
909 }
910
911 #[tokio::test]
912 async fn test_get_stats_counts_expired_entries() {
913 let (config, _temp_dir) = create_test_cache_config();
914 let cache = ResponseCache::new(config).unwrap();
915
916 let key = CacheKey {
917 api_name: "api_a".to_string(),
918 operation_id: "expiredOp".to_string(),
919 request_hash: "expiredhash".to_string(),
920 };
921 let request_info = CachedRequestInfo {
922 method: constants::HTTP_METHOD_GET.to_string(),
923 url: "https://api.example.com/test".to_string(),
924 headers: HashMap::new(),
925 body_hash: None,
926 };
927 cache
928 .store(
929 &key,
930 "body",
931 200,
932 &HashMap::new(),
933 request_info,
934 Some(Duration::from_secs(1)),
935 )
936 .await
937 .unwrap();
938
939 let cache_file = cache.config.cache_dir.join(key.to_filename());
941 let json = tokio::fs::read_to_string(&cache_file).await.unwrap();
942 let mut entry: CachedResponse = serde_json::from_str(&json).unwrap();
943 entry.cached_at = SystemTime::now()
944 .duration_since(UNIX_EPOCH)
945 .unwrap()
946 .as_secs()
947 - 10; tokio::fs::write(&cache_file, serde_json::to_string_pretty(&entry).unwrap())
949 .await
950 .unwrap();
951
952 store_entry(&cache, "api_a", "validOp").await;
954
955 let stats = cache.get_stats(Some("api_a")).await.unwrap();
956 assert_eq!(stats.total_entries, 2);
957 assert_eq!(stats.expired_entries, 1);
958 assert_eq!(stats.valid_entries, 1);
959 }
960
961 #[tokio::test]
966 async fn test_cleanup_removes_stale_tmp_files() {
967 let (config, _temp_dir) = create_test_cache_config();
968 let cache = ResponseCache::new(config.clone()).unwrap();
969
970 let tmp_path = config.cache_dir.join(".orphaned.1a2b3c.tmp");
972 tokio::fs::write(&tmp_path, b"partial write").await.unwrap();
973 assert!(tmp_path.exists(), "temp file must exist before cleanup");
974
975 let epoch = std::time::SystemTime::UNIX_EPOCH;
979 let file = std::fs::OpenOptions::new()
980 .write(true)
981 .open(&tmp_path)
982 .expect("temp file must be openable");
983 file.set_modified(epoch)
984 .expect("setting mtime to epoch must succeed");
985
986 store_entry(&cache, "api_sweep", "op1").await;
988
989 assert!(
990 !tmp_path.exists(),
991 "stale temp file must be removed by cleanup_old_entries"
992 );
993 }
994}