/*! * Local Folder Sync Service Unit Tests * * Tests for local filesystem synchronization functionality including: * - Path validation and access checking * - Recursive directory traversal * - Symlink handling * - File change detection * - Permission handling * - Cross-platform path normalization */ use std::path::{Path, PathBuf}; use std::fs; use tempfile::TempDir; use uuid::Uuid; use chrono::Utc; use serde_json::json; use readur::{ models::{LocalFolderSourceConfig, SourceType}, services::local_folder_service::LocalFolderService, }; /// Create a test local folder configuration fn create_test_local_config() -> LocalFolderSourceConfig { LocalFolderSourceConfig { watch_folders: vec!["/test/documents".to_string(), "/test/images".to_string()], recursive: true, follow_symlinks: false, auto_sync: true, sync_interval_minutes: 30, file_extensions: vec![".pdf".to_string(), ".txt".to_string(), ".jpg".to_string()], } } /// Create a test directory structure fn create_test_directory_structure() -> Result { let temp_dir = TempDir::new()?; let base_path = temp_dir.path(); // Create directory structure fs::create_dir_all(base_path.join("documents"))?; fs::create_dir_all(base_path.join("documents/subfolder"))?; fs::create_dir_all(base_path.join("images"))?; fs::create_dir_all(base_path.join("restricted"))?; // Create test files fs::write(base_path.join("documents/test1.pdf"), b"PDF content")?; fs::write(base_path.join("documents/test2.txt"), b"Text content")?; fs::write(base_path.join("documents/subfolder/nested.pdf"), b"Nested PDF")?; fs::write(base_path.join("images/photo.jpg"), b"Image content")?; fs::write(base_path.join("documents/ignored.exe"), b"Executable")?; fs::write(base_path.join("restricted/secret.txt"), b"Secret content")?; Ok(temp_dir) } #[test] fn test_local_folder_config_creation() { let config = create_test_local_config(); assert_eq!(config.watch_folders.len(), 2); assert_eq!(config.watch_folders[0], "/test/documents"); assert_eq!(config.watch_folders[1], "/test/images"); assert!(config.recursive); assert!(!config.follow_symlinks); assert!(config.auto_sync); assert_eq!(config.sync_interval_minutes, 30); assert_eq!(config.file_extensions.len(), 3); } #[test] fn test_local_folder_config_validation() { let config = create_test_local_config(); // Test paths validation assert!(!config.watch_folders.is_empty(), "Should have at least one path"); for path in &config.watch_folders { assert!(Path::new(path).is_absolute() || path.starts_with('.'), "Path should be absolute or relative: {}", path); } // Test sync interval validation assert!(config.sync_interval_minutes > 0, "Sync interval should be positive"); // Test file extensions validation assert!(!config.file_extensions.is_empty(), "Should have file extensions"); for ext in &config.file_extensions { assert!(ext.starts_with('.'), "Extension should start with dot: {}", ext); } } #[test] fn test_path_normalization() { let test_cases = vec![ ("./documents", "./documents"), ("../documents", "../documents"), ("/home/user/documents", "/home/user/documents"), ("C:\\Users\\test\\Documents", "C:\\Users\\test\\Documents"), ("documents/", "documents"), ("documents//subfolder", "documents/subfolder"), ]; for (input, expected) in test_cases { let normalized = normalize_path(input); // On different platforms, the exact normalization might vary // but we can test basic properties assert!(!normalized.is_empty(), "Normalized path should not be empty"); assert!(!normalized.contains("//"), "Should not contain double slashes"); assert!(!normalized.ends_with('/') || normalized == "/", "Should not end with slash unless root"); } } fn normalize_path(path: &str) -> String { let path = path.trim_end_matches('/'); path.replace("//", "/") } #[test] fn test_file_extension_filtering() { let config = create_test_local_config(); let allowed_extensions = &config.file_extensions; let test_files = vec![ ("document.pdf", true), ("notes.txt", true), ("photo.jpg", true), ("archive.zip", false), ("program.exe", false), ("script.sh", false), ("Document.PDF", true), // Test case insensitivity ("README", false), // No extension (".hidden.txt", true), // Hidden file with allowed extension ]; for (filename, should_be_allowed) in test_files { let extension = extract_extension(filename); let is_allowed = allowed_extensions.contains(&extension); assert_eq!(is_allowed, should_be_allowed, "File {} should be {}", filename, if should_be_allowed { "allowed" } else { "rejected" }); } } fn extract_extension(filename: &str) -> String { if let Some(pos) = filename.rfind('.') { filename[pos..].to_lowercase() } else { String::new() } } #[test] fn test_recursive_directory_traversal() { let temp_dir = create_test_directory_structure().unwrap(); let base_path = temp_dir.path(); // Test recursive traversal let mut files_found = Vec::new(); collect_files_recursive(base_path, &mut files_found).unwrap(); assert!(!files_found.is_empty(), "Should find files in directory structure"); // Should find files in subdirectories when recursive is enabled let nested_files: Vec<_> = files_found.iter() .filter(|f| f.to_string_lossy().contains("subfolder")) .collect(); assert!(!nested_files.is_empty(), "Should find files in subdirectories"); // Test non-recursive traversal let mut files_flat = Vec::new(); collect_files_flat(base_path, &mut files_flat).unwrap(); // Should find fewer files when not recursive assert!(files_flat.len() <= files_found.len(), "Non-recursive should find same or fewer files"); } fn collect_files_recursive(dir: &Path, files: &mut Vec) -> std::io::Result<()> { use walkdir::WalkDir; for entry in WalkDir::new(dir) { let entry = entry?; if entry.file_type().is_file() { files.push(entry.path().to_path_buf()); } } Ok(()) } fn collect_files_flat(dir: &Path, files: &mut Vec) -> std::io::Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; if entry.file_type()?.is_file() { files.push(entry.path()); } } Ok(()) } #[test] fn test_symlink_handling() { let temp_dir = TempDir::new().unwrap(); let base_path = temp_dir.path(); // Create a file and a symlink to it let file_path = base_path.join("original.txt"); fs::write(&file_path, b"Original content").unwrap(); let symlink_path = base_path.join("link.txt"); // Create symlink (this might fail on Windows without admin rights) #[cfg(unix)] { use std::os::unix::fs::symlink; if symlink(&file_path, &symlink_path).is_ok() { // Test with follow_symlinks = true let mut files_with_symlinks = Vec::new(); collect_files_with_symlinks(base_path, true, &mut files_with_symlinks).unwrap(); // Should find both original and symlinked file assert!(files_with_symlinks.len() >= 2, "Should find original and symlinked files"); // Test with follow_symlinks = false let mut files_without_symlinks = Vec::new(); collect_files_with_symlinks(base_path, false, &mut files_without_symlinks).unwrap(); // Should find only original file assert!(files_without_symlinks.len() < files_with_symlinks.len(), "Should find fewer files when not following symlinks"); } } } fn collect_files_with_symlinks(dir: &Path, follow_symlinks: bool, files: &mut Vec) -> std::io::Result<()> { use walkdir::WalkDir; let walker = WalkDir::new(dir).follow_links(follow_symlinks); for entry in walker { let entry = entry?; if entry.file_type().is_file() { files.push(entry.path().to_path_buf()); } } Ok(()) } #[test] fn test_file_metadata_extraction() { let temp_dir = create_test_directory_structure().unwrap(); let base_path = temp_dir.path(); let test_file = base_path.join("documents/test1.pdf"); let metadata = fs::metadata(&test_file).unwrap(); // Test basic metadata assert!(metadata.is_file()); assert!(!metadata.is_dir()); assert!(metadata.len() > 0); // Test modification time let modified = metadata.modified().unwrap(); let now = std::time::SystemTime::now(); assert!(modified <= now, "File modification time should be in the past"); // Test file size let expected_size = "PDF content".len() as u64; assert_eq!(metadata.len(), expected_size); } #[test] fn test_permission_checking() { let temp_dir = create_test_directory_structure().unwrap(); let base_path = temp_dir.path(); // Test readable file let readable_file = base_path.join("documents/test1.pdf"); assert!(readable_file.exists()); assert!(is_readable(&readable_file)); // Test readable directory let readable_dir = base_path.join("documents"); assert!(readable_dir.exists()); assert!(readable_dir.is_dir()); assert!(is_readable(&readable_dir)); // Test non-existent path let non_existent = base_path.join("does_not_exist.txt"); assert!(!non_existent.exists()); assert!(!is_readable(&non_existent)); } fn is_readable(path: &Path) -> bool { path.exists() && fs::metadata(path).is_ok() } #[test] fn test_file_change_detection() { let temp_dir = TempDir::new().unwrap(); let test_file = temp_dir.path().join("test.txt"); // Create initial file fs::write(&test_file, b"Initial content").unwrap(); let initial_metadata = fs::metadata(&test_file).unwrap(); let initial_modified = initial_metadata.modified().unwrap(); let initial_size = initial_metadata.len(); // Wait a bit to ensure timestamp difference std::thread::sleep(std::time::Duration::from_millis(10)); // Modify file fs::write(&test_file, b"Modified content").unwrap(); let modified_metadata = fs::metadata(&test_file).unwrap(); let modified_modified = modified_metadata.modified().unwrap(); let modified_size = modified_metadata.len(); // Test change detection assert_ne!(initial_size, modified_size, "File size should change"); assert!(modified_modified >= initial_modified, "Modification time should advance"); } #[test] fn test_error_handling() { // Test various error scenarios // Non-existent path let non_existent_config = LocalFolderSourceConfig { watch_folders: vec!["/this/path/does/not/exist".to_string()], recursive: true, follow_symlinks: false, auto_sync: true, sync_interval_minutes: 30, file_extensions: vec![".txt".to_string()], }; assert_eq!(non_existent_config.watch_folders[0], "/this/path/does/not/exist"); // Empty paths let empty_paths_config = LocalFolderSourceConfig { watch_folders: Vec::new(), recursive: true, follow_symlinks: false, auto_sync: true, sync_interval_minutes: 30, file_extensions: vec![".txt".to_string()], }; assert!(empty_paths_config.watch_folders.is_empty()); // Invalid sync interval let invalid_interval_config = LocalFolderSourceConfig { watch_folders: vec!["/test".to_string()], recursive: true, follow_symlinks: false, auto_sync: true, sync_interval_minutes: 0, // Invalid file_extensions: vec![".txt".to_string()], }; assert_eq!(invalid_interval_config.sync_interval_minutes, 0); } #[test] fn test_cross_platform_paths() { let test_paths = vec![ ("/home/user/documents", true), // Unix absolute ("./documents", true), // Relative ("../documents", true), // Relative parent ("documents", true), // Relative simple ("C:\\Users\\test", true), // Windows absolute ("", false), // Empty path ]; for (path, should_be_valid) in test_paths { let is_valid = !path.is_empty(); assert_eq!(is_valid, should_be_valid, "Path validation failed for: {}", path); if is_valid { let path_obj = Path::new(path); // Test that we can create a Path object assert_eq!(path_obj.to_string_lossy(), path); } } } #[test] fn test_file_filtering_performance() { // Create a larger set of test files to test filtering performance let temp_dir = TempDir::new().unwrap(); let base_path = temp_dir.path(); // Create many files for i in 0..1000 { let filename = format!("file_{}.txt", i); let filepath = base_path.join(&filename); fs::write(filepath, format!("Content {}", i)).unwrap(); } // Create some files with different extensions for i in 0..100 { let filename = format!("doc_{}.pdf", i); let filepath = base_path.join(&filename); fs::write(filepath, format!("PDF {}", i)).unwrap(); } let config = create_test_local_config(); let start = std::time::Instant::now(); // Simulate filtering let mut matching_files = 0; for entry in fs::read_dir(base_path).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_file() { let filename = entry.file_name().to_string_lossy().to_string(); let extension = extract_extension(&filename); if config.file_extensions.contains(&extension) { matching_files += 1; } } } let elapsed = start.elapsed(); assert!(matching_files > 0, "Should find matching files"); assert!(elapsed < std::time::Duration::from_secs(1), "Filtering should be fast"); } #[test] fn test_concurrent_access_safety() { use std::sync::{Arc, Mutex}; use std::thread; let temp_dir = create_test_directory_structure().unwrap(); let base_path = Arc::new(temp_dir.path().to_path_buf()); let file_count = Arc::new(Mutex::new(0)); // Verify the directory has files before testing concurrent access let initial_count = fs::read_dir(&*base_path).unwrap().count(); assert!(initial_count > 0, "Test directory should contain files"); let mut handles = vec![]; // Spawn multiple threads to read the same directory for _ in 0..4 { let base_path = Arc::clone(&base_path); let file_count = Arc::clone(&file_count); let handle = thread::spawn(move || { let mut local_count = 0; // Recursively count files fn count_files_in_dir(path: &std::path::Path, count: &mut usize) { if let Ok(entries) = fs::read_dir(path) { for entry in entries { if let Ok(entry) = entry { let path = entry.path(); if path.is_file() { *count += 1; } else if path.is_dir() { count_files_in_dir(&path, count); } } } } } count_files_in_dir(&*base_path, &mut local_count); let mut count = file_count.lock().unwrap(); *count += local_count; }); handles.push(handle); } // Wait for all threads to complete for handle in handles { handle.join().unwrap(); } let final_count = *file_count.lock().unwrap(); assert!(final_count > 0, "Should have counted files from multiple threads"); } #[test] fn test_hidden_file_handling() { let temp_dir = TempDir::new().unwrap(); let base_path = temp_dir.path(); // Create regular and hidden files fs::write(base_path.join("visible.txt"), b"Visible content").unwrap(); fs::write(base_path.join(".hidden.txt"), b"Hidden content").unwrap(); // Test file discovery let mut all_files = Vec::new(); for entry in fs::read_dir(base_path).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_file() { all_files.push(entry.file_name().to_string_lossy().to_string()); } } assert!(all_files.contains(&"visible.txt".to_string())); // Hidden file visibility depends on the OS and settings let has_hidden = all_files.iter().any(|f| f.starts_with('.')); println!("Hidden files found: {}", has_hidden); // Filter hidden files if needed let visible_files: Vec<_> = all_files.iter() .filter(|f| !f.starts_with('.')) .collect(); assert!(!visible_files.is_empty(), "Should find at least one visible file"); } #[test] fn test_large_file_handling() { let temp_dir = TempDir::new().unwrap(); let large_file = temp_dir.path().join("large.txt"); // Create a larger file (1MB) let content = "a".repeat(1024 * 1024); fs::write(&large_file, content.as_bytes()).unwrap(); let metadata = fs::metadata(&large_file).unwrap(); assert_eq!(metadata.len(), 1024 * 1024); // Test that we can handle large file metadata efficiently let start = std::time::Instant::now(); let _metadata = fs::metadata(&large_file).unwrap(); let elapsed = start.elapsed(); assert!(elapsed < std::time::Duration::from_millis(100), "Metadata reading should be fast even for large files"); } #[test] fn test_disk_space_estimation() { let temp_dir = create_test_directory_structure().unwrap(); let base_path = temp_dir.path(); let mut total_size = 0u64; let mut file_count = 0u32; for entry in walkdir::WalkDir::new(base_path) { let entry = entry.unwrap(); if entry.file_type().is_file() { if let Ok(metadata) = entry.metadata() { total_size += metadata.len(); file_count += 1; } } } assert!(file_count > 0, "Should count files"); assert!(total_size > 0, "Should calculate total size"); // Calculate average file size let avg_size = if file_count > 0 { total_size / file_count as u64 } else { 0 }; assert!(avg_size > 0, "Should calculate average file size"); }