Skip to main content

msg_gateway/
files.rs

1//! File Cache Module
2//!
3//! Handles downloading, caching, and serving files for inbound/outbound messages.
4
5use 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/// Metadata for a cached file
18#[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, // Unix timestamp
25    pub path: PathBuf,
26}
27
28/// File cache manager
29pub struct FileCache {
30    config: FileCacheConfig,
31    /// In-memory index of cached files
32    files: RwLock<HashMap<String, CachedFile>>,
33    /// Base URL for download links
34    base_url: String,
35}
36
37impl FileCache {
38    /// Create a new file cache
39    pub async fn new(config: FileCacheConfig, gateway_url: &str) -> Result<Self, AppError> {
40        // Ensure cache directory exists
41        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        // Load existing files from disk
52        cache.scan_directory().await?;
53
54        Ok(cache)
55    }
56
57    /// Scan cache directory and rebuild index
58    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            // Look for .meta files
75            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    /// Generate a unique file ID
89    fn generate_file_id() -> String {
90        format!(
91            "f_{}",
92            &uuid::Uuid::new_v4().to_string().replace("-", "")[..12]
93        )
94    }
95
96    /// Validate MIME type against config
97    fn validate_mime_type(&self, mime_type: &str) -> Result<(), AppError> {
98        // Check blocked list first
99        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 allowed list is non-empty, check against it
109        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    /// Download a file from URL and cache it
127    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        // Validate MIME type
135        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        // Download file
141        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        // Check content length if available
161        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        // Determine file extension
184        let ext = Path::new(filename)
185            .extension()
186            .and_then(|e| e.to_str())
187            .unwrap_or("bin");
188
189        // Save file
190        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        // Create metadata
201        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        // Save metadata
216        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        // Add to index
223        {
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    /// Store file data directly (for outbound files from backend)
239    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        // Determine file extension
259        let ext = Path::new(filename)
260            .extension()
261            .and_then(|e| e.to_str())
262            .unwrap_or("bin");
263
264        // Save file
265        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        // Create metadata
272        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        // Save metadata
287        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        // Add to index
294        {
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    /// Get a cached file by ID
310    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    /// Read file content
316    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        // Check if file is expired
323        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    /// Get download URL for a cached file
339    pub fn get_download_url(&self, file_id: &str) -> String {
340        format!("{}/files/{}", self.base_url, file_id)
341    }
342
343    /// Get file path (for passing to adapters)
344    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    /// Delete a cached file
350    #[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            // Delete file and metadata
359            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    /// Run cleanup to remove expired files
370    #[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    /// Get cache statistics
403    #[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
424/// Check if a MIME type matches a pattern (supports wildcards like "image/*")
425fn 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/// Start background cleanup task
438#[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); // "f_" + 12 chars
490        assert_ne!(id1, id2); // Should be unique
491    }
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        // Cleanup
505        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        // Store a file
519        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        // Get the file back
531        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        // Get download URL
537        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        // Cleanup
542        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        // Store a file
556        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        // Verify it exists
563        assert!(cache.get(&cached.file_id).await.is_some());
564
565        // Delete it
566        cache.delete(&cached.file_id).await.unwrap();
567
568        // Verify it's gone
569        assert!(cache.get(&cached.file_id).await.is_none());
570
571        // Cleanup
572        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        // Config with blocked types
581        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        // Should pass - image type is allowed
590        assert!(cache.validate_mime_type("image/png").is_ok());
591
592        // Should pass - text type is allowed
593        assert!(cache.validate_mime_type("text/plain").is_ok());
594
595        // Should fail - blocked type
596        assert!(
597            cache
598                .validate_mime_type("application/x-executable")
599                .is_err()
600        );
601
602        // Should fail - not in allowed list
603        assert!(cache.validate_mime_type("video/mp4").is_err());
604
605        // Cleanup
606        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; // 1 MB limit
616
617        let cache = FileCache::new(config, "http://localhost:8080")
618            .await
619            .unwrap();
620
621        // Create data larger than limit (1.5 MB)
622        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        // Cleanup
630        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        // Empty cache
644        let stats = cache.stats().await;
645        assert_eq!(stats.file_count, 0);
646        assert_eq!(stats.total_bytes, 0);
647
648        // Store some files
649        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        // Cleanup
663        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        // get_download_url always returns a URL regardless of whether file exists
678        let url = cache.get_download_url("nonexistent");
679        assert!(url.contains("nonexistent"));
680        assert!(cache.get_file_path("nonexistent").await.is_none());
681
682        // Cleanup
683        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        // Store a file
697        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        // Read the file back
704        let content = cache.read_file(&cached.file_id).await.unwrap();
705        assert_eq!(content, data);
706
707        // Cleanup
708        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        // Try to read a non-existent file
722        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        // Cleanup
728        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        // Create config with 24 hour TTL
737        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        // Store a file
744        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        // Manually make the file appear expired by setting created_at to epoch
751        {
752            let mut files = cache.files.write().await;
753            if let Some(f) = files.get_mut(&cached.file_id) {
754                f.created_at = 0; // Epoch time = definitely expired
755            }
756        }
757
758        // Try to read - should fail due to expiration
759        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        // Cleanup
765        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        // Create config with 1 hour TTL
774        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        // Store some files
781        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        // Manually make the files appear expired by modifying their created_at
794        // in the in-memory index to be more than 24 hours ago
795        {
796            let mut files = cache.files.write().await;
797            if let Some(f) = files.get_mut(&cached1.file_id) {
798                f.created_at = 0; // Epoch time = definitely expired
799            }
800            if let Some(f) = files.get_mut(&cached2.file_id) {
801                f.created_at = 0;
802            }
803        }
804
805        // Run cleanup
806        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        // Cleanup
813        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        // Create config with long TTL
822        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        // Store some files
829        cache
830            .store_file(b"file1".to_vec(), "f1.txt", "text/plain")
831            .await
832            .unwrap();
833
834        // Run cleanup - should not remove any files
835        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        // Cleanup
842        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        // Store a file
856        let cached = cache
857            .store_file(b"test".to_vec(), "test.txt", "text/plain")
858            .await
859            .unwrap();
860
861        // Get file path
862        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        // Cleanup
869        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        // First, create a cache and store a file
878        {
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        // Cache dropped here, but files remain on disk
893
894        // Create a new cache instance - should scan and find the file
895        {
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        // Cleanup
906        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        // Deleting a non-existent file should succeed (no-op)
920        let result = cache.delete("nonexistent").await;
921        assert!(result.is_ok());
922
923        // Cleanup
924        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        // Store a file without extension
938        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        // Should default to .bin extension
948        assert!(cached.path.to_str().unwrap().ends_with(".bin"));
949
950        // Cleanup
951        let _ = std::fs::remove_dir_all(&temp_dir);
952    }
953
954    #[tokio::test]
955    async fn test_mime_matches_edge_cases() {
956        // Empty pattern should not match
957        assert!(!mime_matches("image/png", ""));
958
959        // Partial prefix without wildcard should not match
960        assert!(!mime_matches("image/png", "image"));
961
962        // Different types should not match
963        assert!(!mime_matches("audio/mp3", "video/*"));
964
965        // Exact match works
966        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        // Config with empty allowed list (should allow everything not blocked)
975        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        // Any type should be allowed (except blocked)
984        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        // Cleanup
989        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        // Serialize
1006        let json = serde_json::to_string(&cached).unwrap();
1007        assert!(json.contains("f_123456789012"));
1008        assert!(json.contains("test.txt"));
1009
1010        // Deserialize
1011        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        // Cleanup
1033        let _ = std::fs::remove_dir_all(&temp_dir);
1034    }
1035}