565 lines
19 KiB
Rust
565 lines
19 KiB
Rust
/*!
|
|
* 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},
|
|
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<TempDir, std::io::Error> {
|
|
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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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");
|
|
} |