1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::{Duration, SystemTime};
10use tokio::fs;
11use tokio::io::AsyncWriteExt;
12use tokio::sync::RwLock;
13
14use crate::config::FileCacheConfig;
15use crate::error::AppError;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CachedFile {
20 pub file_id: String,
21 pub filename: String,
22 pub mime_type: String,
23 pub size_bytes: u64,
24 pub created_at: u64, pub path: PathBuf,
26}
27
28pub struct FileCache {
30 config: FileCacheConfig,
31 files: RwLock<HashMap<String, CachedFile>>,
33 base_url: String,
35}
36
37impl FileCache {
38 pub async fn new(config: FileCacheConfig, gateway_url: &str) -> Result<Self, AppError> {
40 fs::create_dir_all(&config.directory)
42 .await
43 .map_err(|e| AppError::Internal(format!("Failed to create cache directory: {}", e)))?;
44
45 let cache = Self {
46 config,
47 files: RwLock::new(HashMap::new()),
48 base_url: gateway_url.to_string(),
49 };
50
51 cache.scan_directory().await?;
53
54 Ok(cache)
55 }
56
57 async fn scan_directory(&self) -> Result<(), AppError> {
59 let dir = Path::new(&self.config.directory);
60
61 let mut entries = fs::read_dir(dir)
62 .await
63 .map_err(|e| AppError::Internal(format!("Failed to read cache directory: {}", e)))?;
64
65 let mut files = self.files.write().await;
66
67 while let Some(entry) = entries
68 .next_entry()
69 .await
70 .map_err(|e| AppError::Internal(format!("Failed to read directory entry: {}", e)))?
71 {
72 let path = entry.path();
73
74 if path.extension().map(|e| e == "meta").unwrap_or(false)
76 && let Ok(content) = fs::read_to_string(&path).await
77 && let Ok(cached) = serde_json::from_str::<CachedFile>(&content)
78 {
79 files.insert(cached.file_id.clone(), cached);
80 }
81 }
82
83 tracing::info!(cached_files = files.len(), "File cache index loaded");
84
85 Ok(())
86 }
87
88 fn generate_file_id() -> String {
90 format!(
91 "f_{}",
92 &uuid::Uuid::new_v4().to_string().replace("-", "")[..12]
93 )
94 }
95
96 fn validate_mime_type(&self, mime_type: &str) -> Result<(), AppError> {
98 for blocked in &self.config.blocked_mime_types {
100 if mime_matches(mime_type, blocked) {
101 return Err(AppError::Internal(format!(
102 "MIME type {} is blocked",
103 mime_type
104 )));
105 }
106 }
107
108 if !self.config.allowed_mime_types.is_empty() {
110 let allowed = self
111 .config
112 .allowed_mime_types
113 .iter()
114 .any(|pattern| mime_matches(mime_type, pattern));
115 if !allowed {
116 return Err(AppError::Internal(format!(
117 "MIME type {} is not in allowed list",
118 mime_type
119 )));
120 }
121 }
122
123 Ok(())
124 }
125
126 pub async fn download_and_cache(
128 &self,
129 url: &str,
130 auth_header: Option<&str>,
131 filename: &str,
132 mime_type: &str,
133 ) -> Result<CachedFile, AppError> {
134 self.validate_mime_type(mime_type)?;
136
137 let file_id = Self::generate_file_id();
138 let max_size = (self.config.max_file_size_mb as u64) * 1024 * 1024;
139
140 let client = reqwest::Client::new();
142 let mut request = client.get(url);
143
144 if let Some(auth) = auth_header {
145 request = request.header("Authorization", auth);
146 }
147
148 let response = request
149 .send()
150 .await
151 .map_err(|e| AppError::Internal(format!("Failed to download file: {}", e)))?;
152
153 if !response.status().is_success() {
154 return Err(AppError::Internal(format!(
155 "File download failed: {}",
156 response.status()
157 )));
158 }
159
160 if let Some(content_length) = response.content_length()
162 && content_length > max_size
163 {
164 return Err(AppError::Internal(format!(
165 "File too large: {} bytes (max {} MB)",
166 content_length, self.config.max_file_size_mb
167 )));
168 }
169
170 let bytes = response
171 .bytes()
172 .await
173 .map_err(|e| AppError::Internal(format!("Failed to read file content: {}", e)))?;
174
175 if bytes.len() as u64 > max_size {
176 return Err(AppError::Internal(format!(
177 "File too large: {} bytes (max {} MB)",
178 bytes.len(),
179 self.config.max_file_size_mb
180 )));
181 }
182
183 let ext = Path::new(filename)
185 .extension()
186 .and_then(|e| e.to_str())
187 .unwrap_or("bin");
188
189 let file_path = PathBuf::from(&self.config.directory).join(format!("{}.{}", file_id, ext));
191
192 let mut file = fs::File::create(&file_path)
193 .await
194 .map_err(|e| AppError::Internal(format!("Failed to create cache file: {}", e)))?;
195
196 file.write_all(&bytes)
197 .await
198 .map_err(|e| AppError::Internal(format!("Failed to write cache file: {}", e)))?;
199
200 let now = SystemTime::now()
202 .duration_since(SystemTime::UNIX_EPOCH)
203 .unwrap()
204 .as_secs();
205
206 let cached = CachedFile {
207 file_id: file_id.clone(),
208 filename: filename.to_string(),
209 mime_type: mime_type.to_string(),
210 size_bytes: bytes.len() as u64,
211 created_at: now,
212 path: file_path.clone(),
213 };
214
215 let meta_path = PathBuf::from(&self.config.directory).join(format!("{}.meta", file_id));
217 let meta_json = serde_json::to_string_pretty(&cached).unwrap();
218 fs::write(&meta_path, meta_json)
219 .await
220 .map_err(|e| AppError::Internal(format!("Failed to write metadata: {}", e)))?;
221
222 {
224 let mut files = self.files.write().await;
225 files.insert(file_id.clone(), cached.clone());
226 }
227
228 tracing::info!(
229 file_id = %file_id,
230 filename = %filename,
231 size = bytes.len(),
232 "File cached"
233 );
234
235 Ok(cached)
236 }
237
238 pub async fn store_file(
240 &self,
241 data: Vec<u8>,
242 filename: &str,
243 mime_type: &str,
244 ) -> Result<CachedFile, AppError> {
245 self.validate_mime_type(mime_type)?;
246
247 let max_size = (self.config.max_file_size_mb as u64) * 1024 * 1024;
248 if data.len() as u64 > max_size {
249 return Err(AppError::Internal(format!(
250 "File too large: {} bytes (max {} MB)",
251 data.len(),
252 self.config.max_file_size_mb
253 )));
254 }
255
256 let file_id = Self::generate_file_id();
257
258 let ext = Path::new(filename)
260 .extension()
261 .and_then(|e| e.to_str())
262 .unwrap_or("bin");
263
264 let file_path = PathBuf::from(&self.config.directory).join(format!("{}.{}", file_id, ext));
266
267 fs::write(&file_path, &data)
268 .await
269 .map_err(|e| AppError::Internal(format!("Failed to write file: {}", e)))?;
270
271 let now = SystemTime::now()
273 .duration_since(SystemTime::UNIX_EPOCH)
274 .unwrap()
275 .as_secs();
276
277 let cached = CachedFile {
278 file_id: file_id.clone(),
279 filename: filename.to_string(),
280 mime_type: mime_type.to_string(),
281 size_bytes: data.len() as u64,
282 created_at: now,
283 path: file_path.clone(),
284 };
285
286 let meta_path = PathBuf::from(&self.config.directory).join(format!("{}.meta", file_id));
288 let meta_json = serde_json::to_string_pretty(&cached).unwrap();
289 fs::write(&meta_path, meta_json)
290 .await
291 .map_err(|e| AppError::Internal(format!("Failed to write metadata: {}", e)))?;
292
293 {
295 let mut files = self.files.write().await;
296 files.insert(file_id.clone(), cached.clone());
297 }
298
299 tracing::info!(
300 file_id = %file_id,
301 filename = %filename,
302 size = data.len(),
303 "File stored"
304 );
305
306 Ok(cached)
307 }
308
309 pub async fn get(&self, file_id: &str) -> Option<CachedFile> {
311 let files = self.files.read().await;
312 files.get(file_id).cloned()
313 }
314
315 pub async fn read_file(&self, file_id: &str) -> Result<Vec<u8>, AppError> {
317 let cached = self
318 .get(file_id)
319 .await
320 .ok_or_else(|| AppError::NotFound(format!("File not found: {}", file_id)))?;
321
322 let now = SystemTime::now()
324 .duration_since(SystemTime::UNIX_EPOCH)
325 .unwrap()
326 .as_secs();
327
328 let ttl_secs = (self.config.ttl_hours as u64) * 3600;
329 if now - cached.created_at > ttl_secs {
330 return Err(AppError::Gone(format!("File expired: {}", file_id)));
331 }
332
333 fs::read(&cached.path)
334 .await
335 .map_err(|e| AppError::Internal(format!("Failed to read file: {}", e)))
336 }
337
338 pub fn get_download_url(&self, file_id: &str) -> String {
340 format!("{}/files/{}", self.base_url, file_id)
341 }
342
343 pub async fn get_file_path(&self, file_id: &str) -> Option<PathBuf> {
345 let files = self.files.read().await;
346 files.get(file_id).map(|f| f.path.clone())
347 }
348
349 #[allow(dead_code)]
351 pub async fn delete(&self, file_id: &str) -> Result<(), AppError> {
352 let cached = {
353 let mut files = self.files.write().await;
354 files.remove(file_id)
355 };
356
357 if let Some(cached) = cached {
358 let _ = fs::remove_file(&cached.path).await;
360 let meta_path = PathBuf::from(&self.config.directory).join(format!("{}.meta", file_id));
361 let _ = fs::remove_file(&meta_path).await;
362
363 tracing::debug!(file_id = %file_id, "File deleted");
364 }
365
366 Ok(())
367 }
368
369 #[allow(dead_code)]
371 pub async fn cleanup(&self) -> Result<usize, AppError> {
372 let now = SystemTime::now()
373 .duration_since(SystemTime::UNIX_EPOCH)
374 .unwrap()
375 .as_secs();
376
377 let ttl_secs = (self.config.ttl_hours as u64) * 3600;
378 let mut removed = 0;
379
380 let expired: Vec<String> = {
381 let files = self.files.read().await;
382 files
383 .iter()
384 .filter(|(_, f)| now - f.created_at > ttl_secs)
385 .map(|(id, _)| id.clone())
386 .collect()
387 };
388
389 for file_id in expired {
390 if self.delete(&file_id).await.is_ok() {
391 removed += 1;
392 }
393 }
394
395 if removed > 0 {
396 tracing::info!(removed = removed, "Cleaned up expired files");
397 }
398
399 Ok(removed)
400 }
401
402 #[allow(dead_code)]
404 pub async fn stats(&self) -> FileCacheStats {
405 let files = self.files.read().await;
406 let total_bytes: u64 = files.values().map(|f| f.size_bytes).sum();
407
408 FileCacheStats {
409 file_count: files.len(),
410 total_bytes,
411 max_bytes: (self.config.max_cache_size_mb as u64) * 1024 * 1024,
412 }
413 }
414}
415
416#[derive(Debug, Clone, Serialize)]
417#[allow(dead_code)]
418pub struct FileCacheStats {
419 pub file_count: usize,
420 pub total_bytes: u64,
421 pub max_bytes: u64,
422}
423
424fn mime_matches(mime_type: &str, pattern: &str) -> bool {
426 if pattern == "*" || pattern == "*/*" {
427 return true;
428 }
429
430 if let Some(prefix) = pattern.strip_suffix("/*") {
431 return mime_type.starts_with(prefix);
432 }
433
434 mime_type == pattern
435}
436
437#[allow(dead_code)]
439pub async fn start_cleanup_task(cache: Arc<FileCache>, interval_minutes: u32) {
440 let interval = Duration::from_secs((interval_minutes as u64) * 60);
441
442 tracing::info!(
443 interval_minutes = interval_minutes,
444 "Starting file cache cleanup task"
445 );
446
447 loop {
448 tokio::time::sleep(interval).await;
449
450 if let Err(e) = cache.cleanup().await {
451 tracing::error!(error = %e, "File cache cleanup failed");
452 }
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use crate::config::FileCacheConfig;
460
461 fn test_config(dir: &str) -> FileCacheConfig {
462 FileCacheConfig {
463 directory: dir.to_string(),
464 max_file_size_mb: 10,
465 max_cache_size_mb: 100,
466 ttl_hours: 24,
467 cleanup_interval_minutes: 60,
468 allowed_mime_types: vec!["*/*".to_string()],
469 blocked_mime_types: vec![],
470 }
471 }
472
473 #[test]
474 fn test_mime_matches() {
475 assert!(mime_matches("image/png", "image/*"));
476 assert!(mime_matches("image/jpeg", "image/*"));
477 assert!(!mime_matches("text/plain", "image/*"));
478 assert!(mime_matches("text/plain", "text/plain"));
479 assert!(mime_matches("anything", "*"));
480 assert!(mime_matches("anything/here", "*/*"));
481 }
482
483 #[test]
484 fn test_generate_file_id() {
485 let id1 = FileCache::generate_file_id();
486 let id2 = FileCache::generate_file_id();
487
488 assert!(id1.starts_with("f_"));
489 assert_eq!(id1.len(), 14); assert_ne!(id1, id2); }
492
493 #[tokio::test]
494 async fn test_file_cache_new() {
495 let temp_dir = std::env::temp_dir().join("test_file_cache_new");
496 let _ = std::fs::remove_dir_all(&temp_dir);
497
498 let config = test_config(temp_dir.to_str().unwrap());
499 let cache = FileCache::new(config, "http://localhost:8080").await;
500
501 assert!(cache.is_ok());
502 assert!(temp_dir.exists());
503
504 let _ = std::fs::remove_dir_all(&temp_dir);
506 }
507
508 #[tokio::test]
509 async fn test_store_and_get_file() {
510 let temp_dir = std::env::temp_dir().join("test_store_get_file");
511 let _ = std::fs::remove_dir_all(&temp_dir);
512
513 let config = test_config(temp_dir.to_str().unwrap());
514 let cache = FileCache::new(config, "http://localhost:8080")
515 .await
516 .unwrap();
517
518 let data = b"Hello, World!".to_vec();
520 let cached = cache
521 .store_file(data.clone(), "test.txt", "text/plain")
522 .await
523 .unwrap();
524
525 assert!(cached.file_id.starts_with("f_"));
526 assert_eq!(cached.filename, "test.txt");
527 assert_eq!(cached.mime_type, "text/plain");
528 assert_eq!(cached.size_bytes, 13);
529
530 let retrieved = cache.get(&cached.file_id).await;
532 assert!(retrieved.is_some());
533 let retrieved = retrieved.unwrap();
534 assert_eq!(retrieved.filename, "test.txt");
535
536 let url = cache.get_download_url(&cached.file_id);
538 assert!(url.contains(&cached.file_id));
539 assert!(url.starts_with("http://localhost:8080/files/"));
540
541 let _ = std::fs::remove_dir_all(&temp_dir);
543 }
544
545 #[tokio::test]
546 async fn test_delete_file() {
547 let temp_dir = std::env::temp_dir().join("test_delete_file");
548 let _ = std::fs::remove_dir_all(&temp_dir);
549
550 let config = test_config(temp_dir.to_str().unwrap());
551 let cache = FileCache::new(config, "http://localhost:8080")
552 .await
553 .unwrap();
554
555 let data = b"Delete me".to_vec();
557 let cached = cache
558 .store_file(data, "delete.txt", "text/plain")
559 .await
560 .unwrap();
561
562 assert!(cache.get(&cached.file_id).await.is_some());
564
565 cache.delete(&cached.file_id).await.unwrap();
567
568 assert!(cache.get(&cached.file_id).await.is_none());
570
571 let _ = std::fs::remove_dir_all(&temp_dir);
573 }
574
575 #[tokio::test]
576 async fn test_validate_mime_type() {
577 let temp_dir = std::env::temp_dir().join("test_validate_mime");
578 let _ = std::fs::remove_dir_all(&temp_dir);
579
580 let mut config = test_config(temp_dir.to_str().unwrap());
582 config.blocked_mime_types = vec!["application/x-executable".to_string()];
583 config.allowed_mime_types = vec!["image/*".to_string(), "text/*".to_string()];
584
585 let cache = FileCache::new(config, "http://localhost:8080")
586 .await
587 .unwrap();
588
589 assert!(cache.validate_mime_type("image/png").is_ok());
591
592 assert!(cache.validate_mime_type("text/plain").is_ok());
594
595 assert!(
597 cache
598 .validate_mime_type("application/x-executable")
599 .is_err()
600 );
601
602 assert!(cache.validate_mime_type("video/mp4").is_err());
604
605 let _ = std::fs::remove_dir_all(&temp_dir);
607 }
608
609 #[tokio::test]
610 async fn test_file_too_large() {
611 let temp_dir = std::env::temp_dir().join("test_file_too_large");
612 let _ = std::fs::remove_dir_all(&temp_dir);
613
614 let mut config = test_config(temp_dir.to_str().unwrap());
615 config.max_file_size_mb = 1; let cache = FileCache::new(config, "http://localhost:8080")
618 .await
619 .unwrap();
620
621 let data = vec![0u8; 1024 * 1024 + 512 * 1024];
623 let result = cache
624 .store_file(data, "large.bin", "application/octet-stream")
625 .await;
626
627 assert!(result.is_err());
628
629 let _ = std::fs::remove_dir_all(&temp_dir);
631 }
632
633 #[tokio::test]
634 async fn test_stats() {
635 let temp_dir = std::env::temp_dir().join("test_file_stats");
636 let _ = std::fs::remove_dir_all(&temp_dir);
637
638 let config = test_config(temp_dir.to_str().unwrap());
639 let cache = FileCache::new(config, "http://localhost:8080")
640 .await
641 .unwrap();
642
643 let stats = cache.stats().await;
645 assert_eq!(stats.file_count, 0);
646 assert_eq!(stats.total_bytes, 0);
647
648 cache
650 .store_file(b"file1".to_vec(), "f1.txt", "text/plain")
651 .await
652 .unwrap();
653 cache
654 .store_file(b"file2 longer".to_vec(), "f2.txt", "text/plain")
655 .await
656 .unwrap();
657
658 let stats = cache.stats().await;
659 assert_eq!(stats.file_count, 2);
660 assert_eq!(stats.total_bytes, 5 + 12);
661
662 let _ = std::fs::remove_dir_all(&temp_dir);
664 }
665
666 #[tokio::test]
667 async fn test_get_nonexistent() {
668 let temp_dir = std::env::temp_dir().join("test_get_nonexistent");
669 let _ = std::fs::remove_dir_all(&temp_dir);
670
671 let config = test_config(temp_dir.to_str().unwrap());
672 let cache = FileCache::new(config, "http://localhost:8080")
673 .await
674 .unwrap();
675
676 assert!(cache.get("nonexistent").await.is_none());
677 let url = cache.get_download_url("nonexistent");
679 assert!(url.contains("nonexistent"));
680 assert!(cache.get_file_path("nonexistent").await.is_none());
681
682 let _ = std::fs::remove_dir_all(&temp_dir);
684 }
685
686 #[tokio::test]
687 async fn test_read_file() {
688 let temp_dir = std::env::temp_dir().join("test_read_file");
689 let _ = std::fs::remove_dir_all(&temp_dir);
690
691 let config = test_config(temp_dir.to_str().unwrap());
692 let cache = FileCache::new(config, "http://localhost:8080")
693 .await
694 .unwrap();
695
696 let data = b"Hello, World!".to_vec();
698 let cached = cache
699 .store_file(data.clone(), "test.txt", "text/plain")
700 .await
701 .unwrap();
702
703 let content = cache.read_file(&cached.file_id).await.unwrap();
705 assert_eq!(content, data);
706
707 let _ = std::fs::remove_dir_all(&temp_dir);
709 }
710
711 #[tokio::test]
712 async fn test_read_file_not_found() {
713 let temp_dir = std::env::temp_dir().join("test_read_file_not_found");
714 let _ = std::fs::remove_dir_all(&temp_dir);
715
716 let config = test_config(temp_dir.to_str().unwrap());
717 let cache = FileCache::new(config, "http://localhost:8080")
718 .await
719 .unwrap();
720
721 let result = cache.read_file("nonexistent").await;
723 assert!(result.is_err());
724 let err = result.unwrap_err();
725 assert!(matches!(err, crate::error::AppError::NotFound(_)));
726
727 let _ = std::fs::remove_dir_all(&temp_dir);
729 }
730
731 #[tokio::test]
732 async fn test_read_expired_file() {
733 let temp_dir = std::env::temp_dir().join("test_read_expired_file");
734 let _ = std::fs::remove_dir_all(&temp_dir);
735
736 let config = test_config(temp_dir.to_str().unwrap());
738
739 let cache = FileCache::new(config, "http://localhost:8080")
740 .await
741 .unwrap();
742
743 let data = b"This will expire".to_vec();
745 let cached = cache
746 .store_file(data, "expire.txt", "text/plain")
747 .await
748 .unwrap();
749
750 {
752 let mut files = cache.files.write().await;
753 if let Some(f) = files.get_mut(&cached.file_id) {
754 f.created_at = 0; }
756 }
757
758 let result = cache.read_file(&cached.file_id).await;
760 assert!(result.is_err());
761 let err = result.unwrap_err();
762 assert!(matches!(err, crate::error::AppError::Gone(_)));
763
764 let _ = std::fs::remove_dir_all(&temp_dir);
766 }
767
768 #[tokio::test]
769 async fn test_cleanup_expired_files() {
770 let temp_dir = std::env::temp_dir().join("test_cleanup_expired");
771 let _ = std::fs::remove_dir_all(&temp_dir);
772
773 let config = test_config(temp_dir.to_str().unwrap());
775
776 let cache = FileCache::new(config, "http://localhost:8080")
777 .await
778 .unwrap();
779
780 let cached1 = cache
782 .store_file(b"file1".to_vec(), "f1.txt", "text/plain")
783 .await
784 .unwrap();
785 let cached2 = cache
786 .store_file(b"file2".to_vec(), "f2.txt", "text/plain")
787 .await
788 .unwrap();
789
790 let stats_before = cache.stats().await;
791 assert_eq!(stats_before.file_count, 2);
792
793 {
796 let mut files = cache.files.write().await;
797 if let Some(f) = files.get_mut(&cached1.file_id) {
798 f.created_at = 0; }
800 if let Some(f) = files.get_mut(&cached2.file_id) {
801 f.created_at = 0;
802 }
803 }
804
805 let removed = cache.cleanup().await.unwrap();
807 assert_eq!(removed, 2);
808
809 let stats_after = cache.stats().await;
810 assert_eq!(stats_after.file_count, 0);
811
812 let _ = std::fs::remove_dir_all(&temp_dir);
814 }
815
816 #[tokio::test]
817 async fn test_cleanup_no_expired_files() {
818 let temp_dir = std::env::temp_dir().join("test_cleanup_no_expired");
819 let _ = std::fs::remove_dir_all(&temp_dir);
820
821 let config = test_config(temp_dir.to_str().unwrap());
823
824 let cache = FileCache::new(config, "http://localhost:8080")
825 .await
826 .unwrap();
827
828 cache
830 .store_file(b"file1".to_vec(), "f1.txt", "text/plain")
831 .await
832 .unwrap();
833
834 let removed = cache.cleanup().await.unwrap();
836 assert_eq!(removed, 0);
837
838 let stats = cache.stats().await;
839 assert_eq!(stats.file_count, 1);
840
841 let _ = std::fs::remove_dir_all(&temp_dir);
843 }
844
845 #[tokio::test]
846 async fn test_get_file_path() {
847 let temp_dir = std::env::temp_dir().join("test_get_file_path");
848 let _ = std::fs::remove_dir_all(&temp_dir);
849
850 let config = test_config(temp_dir.to_str().unwrap());
851 let cache = FileCache::new(config, "http://localhost:8080")
852 .await
853 .unwrap();
854
855 let cached = cache
857 .store_file(b"test".to_vec(), "test.txt", "text/plain")
858 .await
859 .unwrap();
860
861 let path = cache.get_file_path(&cached.file_id).await;
863 assert!(path.is_some());
864 let path = path.unwrap();
865 assert!(path.exists());
866 assert!(path.to_str().unwrap().contains(&cached.file_id));
867
868 let _ = std::fs::remove_dir_all(&temp_dir);
870 }
871
872 #[tokio::test]
873 async fn test_scan_directory_on_startup() {
874 let temp_dir = std::env::temp_dir().join("test_scan_directory");
875 let _ = std::fs::remove_dir_all(&temp_dir);
876
877 {
879 let config = test_config(temp_dir.to_str().unwrap());
880 let cache = FileCache::new(config, "http://localhost:8080")
881 .await
882 .unwrap();
883
884 cache
885 .store_file(b"persistent".to_vec(), "persist.txt", "text/plain")
886 .await
887 .unwrap();
888
889 let stats = cache.stats().await;
890 assert_eq!(stats.file_count, 1);
891 }
892 {
896 let config = test_config(temp_dir.to_str().unwrap());
897 let cache = FileCache::new(config, "http://localhost:8080")
898 .await
899 .unwrap();
900
901 let stats = cache.stats().await;
902 assert_eq!(stats.file_count, 1);
903 }
904
905 let _ = std::fs::remove_dir_all(&temp_dir);
907 }
908
909 #[tokio::test]
910 async fn test_delete_nonexistent_file() {
911 let temp_dir = std::env::temp_dir().join("test_delete_nonexistent");
912 let _ = std::fs::remove_dir_all(&temp_dir);
913
914 let config = test_config(temp_dir.to_str().unwrap());
915 let cache = FileCache::new(config, "http://localhost:8080")
916 .await
917 .unwrap();
918
919 let result = cache.delete("nonexistent").await;
921 assert!(result.is_ok());
922
923 let _ = std::fs::remove_dir_all(&temp_dir);
925 }
926
927 #[tokio::test]
928 async fn test_store_file_without_extension() {
929 let temp_dir = std::env::temp_dir().join("test_store_no_ext");
930 let _ = std::fs::remove_dir_all(&temp_dir);
931
932 let config = test_config(temp_dir.to_str().unwrap());
933 let cache = FileCache::new(config, "http://localhost:8080")
934 .await
935 .unwrap();
936
937 let cached = cache
939 .store_file(
940 b"binary data".to_vec(),
941 "noextension",
942 "application/octet-stream",
943 )
944 .await
945 .unwrap();
946
947 assert!(cached.path.to_str().unwrap().ends_with(".bin"));
949
950 let _ = std::fs::remove_dir_all(&temp_dir);
952 }
953
954 #[tokio::test]
955 async fn test_mime_matches_edge_cases() {
956 assert!(!mime_matches("image/png", ""));
958
959 assert!(!mime_matches("image/png", "image"));
961
962 assert!(!mime_matches("audio/mp3", "video/*"));
964
965 assert!(mime_matches("application/json", "application/json"));
967 }
968
969 #[tokio::test]
970 async fn test_validate_empty_allowed_list() {
971 let temp_dir = std::env::temp_dir().join("test_validate_empty_allowed");
972 let _ = std::fs::remove_dir_all(&temp_dir);
973
974 let mut config = test_config(temp_dir.to_str().unwrap());
976 config.allowed_mime_types = vec![];
977 config.blocked_mime_types = vec!["application/x-malware".to_string()];
978
979 let cache = FileCache::new(config, "http://localhost:8080")
980 .await
981 .unwrap();
982
983 assert!(cache.validate_mime_type("image/png").is_ok());
985 assert!(cache.validate_mime_type("video/mp4").is_ok());
986 assert!(cache.validate_mime_type("application/x-malware").is_err());
987
988 let _ = std::fs::remove_dir_all(&temp_dir);
990 }
991
992 #[tokio::test]
993 async fn test_cached_file_serialization() {
994 use std::path::PathBuf;
995
996 let cached = CachedFile {
997 file_id: "f_123456789012".to_string(),
998 filename: "test.txt".to_string(),
999 mime_type: "text/plain".to_string(),
1000 size_bytes: 1024,
1001 created_at: 1700000000,
1002 path: PathBuf::from("/tmp/test.txt"),
1003 };
1004
1005 let json = serde_json::to_string(&cached).unwrap();
1007 assert!(json.contains("f_123456789012"));
1008 assert!(json.contains("test.txt"));
1009
1010 let deserialized: CachedFile = serde_json::from_str(&json).unwrap();
1012 assert_eq!(deserialized.file_id, cached.file_id);
1013 assert_eq!(deserialized.filename, cached.filename);
1014 assert_eq!(deserialized.size_bytes, cached.size_bytes);
1015 }
1016
1017 #[tokio::test]
1018 async fn test_file_cache_stats_max_bytes() {
1019 let temp_dir = std::env::temp_dir().join("test_stats_max_bytes");
1020 let _ = std::fs::remove_dir_all(&temp_dir);
1021
1022 let mut config = test_config(temp_dir.to_str().unwrap());
1023 config.max_cache_size_mb = 50;
1024
1025 let cache = FileCache::new(config, "http://localhost:8080")
1026 .await
1027 .unwrap();
1028
1029 let stats = cache.stats().await;
1030 assert_eq!(stats.max_bytes, 50 * 1024 * 1024);
1031
1032 let _ = std::fs::remove_dir_all(&temp_dir);
1034 }
1035}