/*! * WebDAV Sync Service Unit Tests * * Tests for WebDAV synchronization functionality including: * - Connection testing and validation * - File discovery and enumeration * - ETag-based change detection * - File download and processing * - Error handling and retry logic * - Server type detection (Nextcloud, ownCloud, etc.) */ use std::collections::HashMap; use uuid::Uuid; use chrono::Utc; use serde_json::json; use readur::{ models::{WebDAVSourceConfig, SourceType, WebDAVFile, WebDAVCrawlEstimate, WebDAVFolderInfo}, services::webdav::{WebDAVService, WebDAVConfig}, }; /// Create a test WebDAV configuration fn create_test_webdav_config() -> WebDAVConfig { WebDAVConfig { server_url: "https://cloud.example.com".to_string(), username: "testuser".to_string(), password: "testpass".to_string(), watch_folders: vec!["/Documents".to_string(), "/Photos".to_string()], file_extensions: vec![".pdf".to_string(), ".txt".to_string(), ".jpg".to_string()], timeout_seconds: 30, server_type: Some("nextcloud".to_string()), } } /// Create a test WebDAV source configuration fn create_test_source_config() -> WebDAVSourceConfig { WebDAVSourceConfig { server_url: "https://cloud.example.com".to_string(), username: "testuser".to_string(), password: "testpass".to_string(), watch_folders: vec!["/Documents".to_string()], file_extensions: vec![".pdf".to_string(), ".txt".to_string()], auto_sync: true, sync_interval_minutes: 60, server_type: Some("nextcloud".to_string()), } } #[test] fn test_webdav_config_creation() { let config = create_test_webdav_config(); assert_eq!(config.server_url, "https://cloud.example.com"); assert_eq!(config.username, "testuser"); assert_eq!(config.password, "testpass"); assert_eq!(config.watch_folders.len(), 2); assert_eq!(config.file_extensions.len(), 3); assert_eq!(config.timeout_seconds, 30); assert_eq!(config.server_type, Some("nextcloud".to_string())); } #[test] fn test_webdav_source_config_creation() { let config = create_test_source_config(); assert_eq!(config.server_url, "https://cloud.example.com"); assert_eq!(config.username, "testuser"); assert!(config.auto_sync); assert_eq!(config.sync_interval_minutes, 60); assert_eq!(config.server_type, Some("nextcloud".to_string())); } #[test] fn test_webdav_config_validation() { let config = create_test_webdav_config(); // Test URL validation assert!(config.server_url.starts_with("https://")); assert!(!config.server_url.is_empty()); // Test credentials validation assert!(!config.username.is_empty()); assert!(!config.password.is_empty()); // Test folders validation assert!(!config.watch_folders.is_empty()); for folder in &config.watch_folders { assert!(folder.starts_with('/')); } // Test extensions validation assert!(!config.file_extensions.is_empty()); for ext in &config.file_extensions { assert!(ext.starts_with('.')); } // Test timeout validation assert!(config.timeout_seconds > 0); assert!(config.timeout_seconds <= 300); // Max 5 minutes } #[test] fn test_webdav_file_structure() { let webdav_file = WebDAVFile { id: Uuid::new_v4(), user_id: Uuid::new_v4(), webdav_path: "/Documents/test.pdf".to_string(), etag: "abc123".to_string(), last_modified: Some(Utc::now()), file_size: 1024, mime_type: "application/pdf".to_string(), document_id: None, sync_status: "synced".to_string(), sync_error: None, created_at: Utc::now(), updated_at: Utc::now(), }; assert_eq!(webdav_file.webdav_path, "/Documents/test.pdf"); assert_eq!(webdav_file.etag, "abc123"); assert_eq!(webdav_file.file_size, 1024); assert_eq!(webdav_file.mime_type, "application/pdf"); // Test filename extraction let filename = webdav_file.webdav_path.split('/').last().unwrap(); assert_eq!(filename, "test.pdf"); // Test extension detection let extension = filename.split('.').last().unwrap(); assert_eq!(extension, "pdf"); } #[test] fn test_file_extension_filtering() { let config = create_test_webdav_config(); let allowed_extensions = &config.file_extensions; // Test allowed files assert!(allowed_extensions.contains(&".pdf".to_string())); assert!(allowed_extensions.contains(&".txt".to_string())); assert!(allowed_extensions.contains(&".jpg".to_string())); // Test disallowed files assert!(!allowed_extensions.contains(&".exe".to_string())); assert!(!allowed_extensions.contains(&".bat".to_string())); assert!(!allowed_extensions.contains(&".sh".to_string())); // Test case sensitivity let test_files = vec![ ("document.PDF", ".pdf"), ("notes.TXT", ".txt"), ("image.JPG", ".jpg"), ("archive.ZIP", ".zip"), ]; for (filename, expected_ext) in test_files { let ext = format!(".{}", filename.split('.').last().unwrap().to_lowercase()); assert_eq!(ext, expected_ext); if allowed_extensions.contains(&ext) { println!("✅ File {} would be processed", filename); } else { println!("❌ File {} would be skipped", filename); } } } #[test] fn test_etag_change_detection() { let old_etag = "abc123"; let new_etag = "def456"; let same_etag = "abc123"; // Test change detection assert_ne!(old_etag, new_etag, "Different ETags should indicate file change"); assert_eq!(old_etag, same_etag, "Same ETags should indicate no change"); // Test ETag normalization (some servers use quotes) let quoted_etag = "\"abc123\""; let normalized_etag = quoted_etag.trim_matches('"'); assert_eq!(normalized_etag, old_etag); } #[test] fn test_etag_normalization() { // Test various ETag formats that WebDAV servers might return let test_cases = vec![ ("abc123", "abc123"), // Plain ETag ("\"abc123\"", "abc123"), // Quoted ETag ("W/\"abc123\"", "abc123"), // Weak ETag ("\"abc-123-def\"", "abc-123-def"), // Quoted with dashes ("W/\"abc-123-def\"", "abc-123-def"), // Weak ETag with dashes ]; for (input, expected) in test_cases { let normalized = input .trim_start_matches("W/") .trim_matches('"'); assert_eq!(normalized, expected, "Failed to normalize ETag: {} -> expected {}", input, expected); } } #[test] fn test_etag_comparison_fixes_duplicate_downloads() { // This test demonstrates how ETag normalization prevents unnecessary downloads // Simulate a WebDAV server that returns quoted ETags let server_etag = "\"file-hash-123\""; // Before fix: stored ETag would have quotes, server ETag would have quotes // After fix: both should be normalized (no quotes) let normalized_server = server_etag.trim_start_matches("W/").trim_matches('"'); let normalized_stored = "file-hash-123"; // What would be stored after normalization // These should match after normalization, preventing redownload assert_eq!(normalized_server, normalized_stored, "Normalized ETags should match to prevent unnecessary redownloads"); // Demonstrate the issue that was fixed let old_behavior_would_mismatch = (server_etag != normalized_stored); assert!(old_behavior_would_mismatch, "Before fix: quoted vs unquoted ETags would cause unnecessary downloads"); let new_behavior_matches = (normalized_server == normalized_stored); assert!(new_behavior_matches, "After fix: normalized ETags match, preventing unnecessary downloads"); } #[test] fn test_path_normalization() { let test_paths = vec![ ("/Documents/test.pdf", "/Documents/test.pdf"), ("Documents/test.pdf", "/Documents/test.pdf"), ("/Documents//test.pdf", "/Documents/test.pdf"), ("/Documents/./test.pdf", "/Documents/test.pdf"), ]; for (input, expected) in test_paths { let normalized = normalize_webdav_path(input); assert_eq!(normalized, expected, "Path normalization failed for: {}", input); } } fn normalize_webdav_path(path: &str) -> String { let mut normalized = path.to_string(); // Ensure path starts with / if !normalized.starts_with('/') { normalized = format!("/{}", normalized); } // Remove double slashes while normalized.contains("//") { normalized = normalized.replace("//", "/"); } // Remove ./ references normalized = normalized.replace("/./", "/"); normalized } #[test] fn test_crawl_estimate_structure() { let estimate = WebDAVCrawlEstimate { folders: vec![ WebDAVFolderInfo { path: "/Documents".to_string(), total_files: 10, supported_files: 8, estimated_time_hours: 0.5, total_size_mb: 50.0 }, WebDAVFolderInfo { path: "/Photos".to_string(), total_files: 100, supported_files: 90, estimated_time_hours: 2.0, total_size_mb: 500.0 } ], total_files: 110, total_supported_files: 98, total_estimated_time_hours: 2.5, total_size_mb: 550.0, }; assert_eq!(estimate.folders.len(), 2); assert_eq!(estimate.total_files, 110); assert_eq!(estimate.total_supported_files, 98); assert_eq!(estimate.total_estimated_time_hours, 2.5); assert_eq!(estimate.total_size_mb, 550.0); // Test calculation accuracy let calculated_files: i64 = estimate.folders.iter() .map(|f| f.total_files) .sum(); assert_eq!(calculated_files, estimate.total_files); let calculated_supported: i64 = estimate.folders.iter() .map(|f| f.supported_files) .sum(); assert_eq!(calculated_supported, estimate.total_supported_files); } #[test] fn test_webdav_url_construction() { let base_url = "https://cloud.example.com"; let folder = "/Documents"; let filename = "test.pdf"; // Test DAV endpoint construction let dav_url = format!("{}/remote.php/dav/files/testuser{}", base_url, folder); assert_eq!(dav_url, "https://cloud.example.com/remote.php/dav/files/testuser/Documents"); // Test file URL construction let file_url = format!("{}/{}", dav_url, filename); assert_eq!(file_url, "https://cloud.example.com/remote.php/dav/files/testuser/Documents/test.pdf"); // Test URL encoding (spaces and special characters) let special_filename = "my document (1).pdf"; let encoded_filename = urlencoding::encode(special_filename); let encoded_url = format!("{}/{}", dav_url, encoded_filename); assert!(encoded_url.contains("my%20document%20%281%29.pdf")); } #[test] fn test_server_type_detection() { let server_types = vec![ ("nextcloud", true), ("owncloud", true), ("apache", false), ("nginx", false), ("unknown", false), ]; for (server_type, is_supported) in server_types { let config = WebDAVConfig { server_url: "https://test.com".to_string(), username: "test".to_string(), password: "test".to_string(), watch_folders: vec!["/test".to_string()], file_extensions: vec![".pdf".to_string()], timeout_seconds: 30, server_type: Some(server_type.to_string()), }; if is_supported { assert!(["nextcloud", "owncloud"].contains(&config.server_type.as_ref().unwrap().as_str())); } else { assert!(!["nextcloud", "owncloud"].contains(&config.server_type.as_ref().unwrap().as_str())); } } } #[test] fn test_error_handling_scenarios() { // Test various error scenarios that might occur during sync // Network timeout scenario let timeout_config = WebDAVConfig { server_url: "https://invalid-server.com".to_string(), username: "test".to_string(), password: "test".to_string(), watch_folders: vec!["/test".to_string()], file_extensions: vec![".pdf".to_string()], timeout_seconds: 1, // Very short timeout server_type: Some("nextcloud".to_string()), }; assert_eq!(timeout_config.timeout_seconds, 1); // Authentication error scenario let auth_config = WebDAVConfig { server_url: "https://cloud.example.com".to_string(), username: "invalid_user".to_string(), password: "wrong_password".to_string(), watch_folders: vec!["/test".to_string()], file_extensions: vec![".pdf".to_string()], timeout_seconds: 30, server_type: Some("nextcloud".to_string()), }; assert_eq!(auth_config.username, "invalid_user"); assert_eq!(auth_config.password, "wrong_password"); // Invalid folder path scenario let invalid_path_config = WebDAVConfig { server_url: "https://cloud.example.com".to_string(), username: "test".to_string(), password: "test".to_string(), watch_folders: vec!["/nonexistent_folder".to_string()], file_extensions: vec![".pdf".to_string()], timeout_seconds: 30, server_type: Some("nextcloud".to_string()), }; assert_eq!(invalid_path_config.watch_folders[0], "/nonexistent_folder"); } #[test] fn test_sync_performance_metrics() { // Test metrics that would be important for sync performance let sync_stats = SyncStats { files_discovered: 100, files_downloaded: 85, files_skipped: 10, files_failed: 5, total_bytes_downloaded: 10_000_000, // 10MB sync_duration_ms: 30_000, // 30 seconds average_download_speed_mbps: 2.67, }; assert_eq!(sync_stats.files_discovered, 100); assert_eq!(sync_stats.files_downloaded, 85); assert_eq!(sync_stats.files_skipped, 10); assert_eq!(sync_stats.files_failed, 5); // Test calculated metrics let total_processed = sync_stats.files_downloaded + sync_stats.files_skipped + sync_stats.files_failed; assert_eq!(total_processed, sync_stats.files_discovered); let success_rate = (sync_stats.files_downloaded as f64 / sync_stats.files_discovered as f64) * 100.0; assert!(success_rate >= 80.0, "Success rate should be at least 80%"); let mb_downloaded = sync_stats.total_bytes_downloaded as f64 / 1_000_000.0; let seconds = sync_stats.sync_duration_ms as f64 / 1000.0; let calculated_speed = (mb_downloaded * 8.0) / seconds; // Convert to Mbps assert!((calculated_speed - sync_stats.average_download_speed_mbps).abs() < 0.1); } #[derive(Debug, Clone)] struct SyncStats { files_discovered: u32, files_downloaded: u32, files_skipped: u32, files_failed: u32, total_bytes_downloaded: u64, sync_duration_ms: u64, average_download_speed_mbps: f64, } #[test] fn test_concurrent_sync_protection() { // Test data structures that would prevent concurrent syncs use std::sync::{Arc, Mutex}; use std::collections::HashSet; let active_syncs: Arc>> = Arc::new(Mutex::new(HashSet::new())); let source_id = Uuid::new_v4(); // Test adding a sync { let mut syncs = active_syncs.lock().unwrap(); let was_inserted = syncs.insert(source_id); assert!(was_inserted, "First sync should be allowed"); } // Test preventing duplicate sync { let mut syncs = active_syncs.lock().unwrap(); let was_inserted = syncs.insert(source_id); assert!(!was_inserted, "Duplicate sync should be prevented"); } // Test removing completed sync { let mut syncs = active_syncs.lock().unwrap(); let was_removed = syncs.remove(&source_id); assert!(was_removed, "Sync should be removable after completion"); } } #[test] fn test_file_hash_comparison() { use sha2::{Sha256, Digest}; // Test SHA256 hash generation for file deduplication let file_content_1 = b"This is test file content"; let file_content_2 = b"This is different content"; let file_content_3 = b"This is test file content"; // Same as 1 let hash_1 = Sha256::digest(file_content_1); let hash_2 = Sha256::digest(file_content_2); let hash_3 = Sha256::digest(file_content_3); assert_ne!(hash_1, hash_2, "Different content should have different hashes"); assert_eq!(hash_1, hash_3, "Same content should have same hashes"); // Test hex encoding let hash_1_hex = format!("{:x}", hash_1); let hash_3_hex = format!("{:x}", hash_3); assert_eq!(hash_1_hex, hash_3_hex); assert_eq!(hash_1_hex.len(), 64); // SHA256 is 64 hex characters } #[test] fn test_retry_mechanism() { // Test exponential backoff for retry logic fn calculate_retry_delay(attempt: u32, base_delay_ms: u64) -> u64 { let max_delay_ms = 30_000; // 30 seconds max let delay = base_delay_ms * 2_u64.pow(attempt.saturating_sub(1)); std::cmp::min(delay, max_delay_ms) } assert_eq!(calculate_retry_delay(1, 1000), 1000); // 1 second assert_eq!(calculate_retry_delay(2, 1000), 2000); // 2 seconds assert_eq!(calculate_retry_delay(3, 1000), 4000); // 4 seconds assert_eq!(calculate_retry_delay(4, 1000), 8000); // 8 seconds assert_eq!(calculate_retry_delay(5, 1000), 16000); // 16 seconds assert_eq!(calculate_retry_delay(6, 1000), 30000); // Capped at 30 seconds assert_eq!(calculate_retry_delay(10, 1000), 30000); // Still capped at 30 seconds } #[test] fn test_bandwidth_limiting() { // Test bandwidth limiting calculations fn calculate_download_delay(bytes_downloaded: u64, target_mbps: f64) -> u64 { if target_mbps <= 0.0 { return 0; // No limit } let bits_downloaded = bytes_downloaded * 8; let target_bps = target_mbps * 1_000_000.0; let ideal_duration_ms = (bits_downloaded as f64 / target_bps * 1000.0) as u64; ideal_duration_ms } // Test with 1 Mbps limit let delay_1mb = calculate_download_delay(125_000, 1.0); // 1 Mb of data assert_eq!(delay_1mb, 1000); // Should take 1 second // Test with 10 Mbps limit let delay_10mb = calculate_download_delay(125_000, 10.0); // 1 Mb of data assert_eq!(delay_10mb, 100); // Should take 0.1 seconds // Test with no limit let delay_unlimited = calculate_download_delay(125_000, 0.0); assert_eq!(delay_unlimited, 0); // No delay }