feat(unit): add unit tests for sources

This commit is contained in:
perf3ct
2025-06-15 18:35:01 +00:00
parent 0cc77ed8ac
commit da489618d1
11 changed files with 6263 additions and 0 deletions
+579
View File
@@ -0,0 +1,579 @@
/*!
* Auto-Resume Functionality Unit Tests
*
* Tests for auto-resume sync functionality including:
* - Server restart detection and recovery
* - 30-second startup delay
* - Interrupted sync detection
* - State cleanup and restoration
* - User notifications for resumed syncs
* - Error handling during resume
*/
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use uuid::Uuid;
use chrono::{Utc, DateTime};
use serde_json::json;
use tokio::time::{sleep, timeout};
use readur::{
AppState,
config::Config,
db::Database,
models::{Source, SourceType, SourceStatus, WebDAVSourceConfig, CreateNotification},
source_scheduler::SourceScheduler,
};
/// Create a test app state
async fn create_test_app_state() -> Arc<AppState> {
let config = Config {
database_url: "sqlite::memory:".to_string(),
server_address: "127.0.0.1:8080".to_string(),
jwt_secret: "test_secret".to_string(),
upload_dir: "/tmp/test_uploads".to_string(),
max_file_size: 10 * 1024 * 1024,
};
let db = Database::new(&config.database_url).await.unwrap();
Arc::new(AppState {
db,
config,
webdav_scheduler: None,
source_scheduler: None,
})
}
/// Create a source that appears to be interrupted during sync
fn create_interrupted_source(user_id: Uuid, source_type: SourceType) -> Source {
let mut config = json!({});
match source_type {
SourceType::WebDAV => {
config = json!({
"server_url": "https://cloud.example.com",
"username": "testuser",
"password": "testpass",
"watch_folders": ["/Documents"],
"file_extensions": [".pdf", ".txt"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
});
},
SourceType::LocalFolder => {
config = json!({
"paths": ["/home/user/documents"],
"recursive": true,
"follow_symlinks": false,
"auto_sync": true,
"sync_interval_minutes": 30,
"file_extensions": [".pdf", ".txt"]
});
},
SourceType::S3 => {
config = json!({
"bucket": "test-bucket",
"region": "us-east-1",
"access_key_id": "AKIATEST",
"secret_access_key": "secrettest",
"prefix": "documents/",
"auto_sync": true,
"sync_interval_minutes": 120,
"file_extensions": [".pdf", ".docx"]
});
}
}
Source {
id: Uuid::new_v4(),
user_id,
name: format!("Interrupted {} Source", source_type.to_string()),
source_type,
enabled: true,
config,
status: SourceStatus::Syncing, // This indicates interruption
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(10)), // Started 10 min ago
last_error: None,
last_error_at: None,
total_files_synced: 5, // Some progress was made
total_files_pending: 15, // Still work to do
total_size_bytes: 10_000_000, // 10MB
created_at: Utc::now() - chrono::Duration::hours(1),
updated_at: Utc::now() - chrono::Duration::minutes(10),
}
}
/// Create a source that completed successfully
fn create_completed_source(user_id: Uuid) -> Source {
Source {
id: Uuid::new_v4(),
user_id,
name: "Completed Source".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://cloud.example.com",
"username": "testuser",
"password": "testpass",
"watch_folders": ["/Documents"],
"file_extensions": [".pdf"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
}),
status: SourceStatus::Idle, // Completed normally
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(30)),
last_error: None,
last_error_at: None,
total_files_synced: 20,
total_files_pending: 0,
total_size_bytes: 50_000_000,
created_at: Utc::now() - chrono::Duration::hours(2),
updated_at: Utc::now() - chrono::Duration::minutes(30),
}
}
#[tokio::test]
async fn test_interrupted_sync_detection() {
let user_id = Uuid::new_v4();
// Test detection for each source type
let webdav_source = create_interrupted_source(user_id, SourceType::WebDAV);
let local_source = create_interrupted_source(user_id, SourceType::LocalFolder);
let s3_source = create_interrupted_source(user_id, SourceType::S3);
// All should be detected as interrupted
assert!(is_interrupted_sync(&webdav_source), "WebDAV source should be detected as interrupted");
assert!(is_interrupted_sync(&local_source), "Local folder source should be detected as interrupted");
assert!(is_interrupted_sync(&s3_source), "S3 source should be detected as interrupted");
// Test that completed source is not detected as interrupted
let completed_source = create_completed_source(user_id);
assert!(!is_interrupted_sync(&completed_source), "Completed source should not be detected as interrupted");
}
fn is_interrupted_sync(source: &Source) -> bool {
// A source is considered interrupted if it's in "syncing" status
// but the server has restarted (which we simulate here)
source.status == SourceStatus::Syncing
}
#[tokio::test]
async fn test_auto_sync_configuration_check() {
let user_id = Uuid::new_v4();
// Test WebDAV source with auto_sync enabled
let webdav_enabled = create_interrupted_source(user_id, SourceType::WebDAV);
let should_resume = should_auto_resume_sync(&webdav_enabled);
assert!(should_resume, "WebDAV source with auto_sync should be resumed");
// Test source with auto_sync disabled
let mut webdav_disabled = create_interrupted_source(user_id, SourceType::WebDAV);
webdav_disabled.config = json!({
"server_url": "https://cloud.example.com",
"username": "testuser",
"password": "testpass",
"watch_folders": ["/Documents"],
"file_extensions": [".pdf"],
"auto_sync": false, // Disabled
"sync_interval_minutes": 60,
"server_type": "nextcloud"
});
let should_not_resume = should_auto_resume_sync(&webdav_disabled);
assert!(!should_not_resume, "Source with auto_sync disabled should not be resumed");
}
fn should_auto_resume_sync(source: &Source) -> bool {
if !is_interrupted_sync(source) {
return false;
}
// Check auto_sync setting based on source type
match source.source_type {
SourceType::WebDAV => {
if let Ok(config) = serde_json::from_value::<WebDAVSourceConfig>(source.config.clone()) {
config.auto_sync
} else { false }
},
SourceType::LocalFolder => {
if let Ok(config) = serde_json::from_value::<readur::models::LocalFolderSourceConfig>(source.config.clone()) {
config.auto_sync
} else { false }
},
SourceType::S3 => {
if let Ok(config) = serde_json::from_value::<readur::models::S3SourceConfig>(source.config.clone()) {
config.auto_sync
} else { false }
}
}
}
#[tokio::test]
async fn test_startup_delay_timing() {
let start_time = std::time::Instant::now();
// Simulate the 30-second startup delay
let delay_duration = Duration::from_secs(30);
// In a real test, we might use a shorter delay for speed
let test_delay = Duration::from_millis(100); // Shortened for testing
sleep(test_delay).await;
let elapsed = start_time.elapsed();
assert!(elapsed >= test_delay, "Should wait for at least the specified delay");
// Test that the delay is configurable
let configurable_delay = get_startup_delay_from_config();
assert_eq!(configurable_delay, Duration::from_secs(30));
}
fn get_startup_delay_from_config() -> Duration {
// In real implementation, this might come from configuration
Duration::from_secs(30)
}
#[test]
fn test_sync_state_cleanup() {
let user_id = Uuid::new_v4();
let interrupted_source = create_interrupted_source(user_id, SourceType::WebDAV);
// Test state cleanup (reset from syncing to idle)
let cleaned_status = cleanup_interrupted_status(&interrupted_source.status);
assert_eq!(cleaned_status, SourceStatus::Idle);
// Test that other statuses are not affected
let idle_status = cleanup_interrupted_status(&SourceStatus::Idle);
assert_eq!(idle_status, SourceStatus::Idle);
let error_status = cleanup_interrupted_status(&SourceStatus::Error);
assert_eq!(error_status, SourceStatus::Error);
}
fn cleanup_interrupted_status(status: &SourceStatus) -> SourceStatus {
match status {
SourceStatus::Syncing => SourceStatus::Idle, // Reset interrupted syncs
other => other.clone(), // Keep other statuses as-is
}
}
#[test]
fn test_resume_notification_creation() {
let user_id = Uuid::new_v4();
let source = create_interrupted_source(user_id, SourceType::WebDAV);
let files_processed = 12;
let notification = create_resume_notification(&source, files_processed);
assert_eq!(notification.notification_type, "success");
assert_eq!(notification.title, "Source Sync Resumed");
assert!(notification.message.contains(&source.name));
assert!(notification.message.contains(&files_processed.to_string()));
assert_eq!(notification.action_url, Some("/sources".to_string()));
// Check metadata
assert!(notification.metadata.is_some());
let metadata = notification.metadata.unwrap();
assert_eq!(metadata["source_type"], source.source_type.to_string());
assert_eq!(metadata["source_id"], source.id.to_string());
assert_eq!(metadata["files_processed"], files_processed);
}
fn create_resume_notification(source: &Source, files_processed: u32) -> CreateNotification {
CreateNotification {
notification_type: "success".to_string(),
title: "Source Sync Resumed".to_string(),
message: format!(
"Resumed sync for {} after server restart. Processed {} files",
source.name, files_processed
),
action_url: Some("/sources".to_string()),
metadata: Some(json!({
"source_type": source.source_type.to_string(),
"source_id": source.id,
"files_processed": files_processed
})),
}
}
#[test]
fn test_resume_error_notification() {
let user_id = Uuid::new_v4();
let source = create_interrupted_source(user_id, SourceType::S3);
let error_message = "S3 bucket access denied";
let notification = create_resume_error_notification(&source, error_message);
assert_eq!(notification.notification_type, "error");
assert_eq!(notification.title, "Source Sync Resume Failed");
assert!(notification.message.contains(&source.name));
assert!(notification.message.contains(error_message));
let metadata = notification.metadata.unwrap();
assert_eq!(metadata["error"], error_message);
}
fn create_resume_error_notification(source: &Source, error: &str) -> CreateNotification {
CreateNotification {
notification_type: "error".to_string(),
title: "Source Sync Resume Failed".to_string(),
message: format!("Failed to resume sync for {}: {}", source.name, error),
action_url: Some("/sources".to_string()),
metadata: Some(json!({
"source_type": source.source_type.to_string(),
"source_id": source.id,
"error": error
})),
}
}
#[tokio::test]
async fn test_resume_with_timeout() {
let user_id = Uuid::new_v4();
let source = create_interrupted_source(user_id, SourceType::WebDAV);
// Test that resume operation can timeout
let resume_timeout = Duration::from_secs(5);
let result = timeout(resume_timeout, simulate_resume_operation(&source)).await;
match result {
Ok(resume_result) => {
assert!(resume_result.is_ok(), "Resume should succeed within timeout");
},
Err(_) => {
// Timeout occurred - this is also a valid test scenario
println!("Resume operation timed out (expected in some test scenarios)");
}
}
}
async fn simulate_resume_operation(source: &Source) -> Result<u32, String> {
// Simulate some work
sleep(Duration::from_millis(100)).await;
// Return number of files processed
Ok(source.total_files_pending)
}
#[test]
fn test_resume_priority_ordering() {
let user_id = Uuid::new_v4();
// Create sources with different types and interruption times
let mut sources = vec![
create_interrupted_source_with_time(user_id, SourceType::S3, 60), // 1 hour ago
create_interrupted_source_with_time(user_id, SourceType::LocalFolder, 30), // 30 min ago
create_interrupted_source_with_time(user_id, SourceType::WebDAV, 120), // 2 hours ago
];
// Sort by resume priority
sources.sort_by_key(|s| get_resume_priority(s));
// Local folder should have highest priority (lowest number)
assert_eq!(sources[0].source_type, SourceType::LocalFolder);
// WebDAV should be next
assert_eq!(sources[1].source_type, SourceType::WebDAV);
// S3 should have lowest priority
assert_eq!(sources[2].source_type, SourceType::S3);
}
fn create_interrupted_source_with_time(user_id: Uuid, source_type: SourceType, minutes_ago: i64) -> Source {
let mut source = create_interrupted_source(user_id, source_type);
source.last_sync_at = Some(Utc::now() - chrono::Duration::minutes(minutes_ago));
source
}
fn get_resume_priority(source: &Source) -> u32 {
// Lower number = higher priority
let type_priority = match source.source_type {
SourceType::LocalFolder => 1, // Highest priority (fastest)
SourceType::WebDAV => 2, // Medium priority
SourceType::S3 => 3, // Lower priority (potential costs)
};
// Consider how long ago the sync was interrupted
let time_penalty = if let Some(last_sync) = source.last_sync_at {
let minutes_ago = (Utc::now() - last_sync).num_minutes();
(minutes_ago / 30) as u32 // Add 1 to priority for every 30 minutes
} else {
10 // High penalty for unknown last sync time
};
type_priority + time_penalty
}
#[test]
fn test_resume_batch_processing() {
let user_id = Uuid::new_v4();
// Create multiple interrupted sources
let sources = vec![
create_interrupted_source(user_id, SourceType::WebDAV),
create_interrupted_source(user_id, SourceType::LocalFolder),
create_interrupted_source(user_id, SourceType::S3),
create_interrupted_source(user_id, SourceType::WebDAV),
];
// Test batching by source type
let batches = group_sources_by_type(&sources);
assert_eq!(batches.len(), 3); // Three different types
assert!(batches.contains_key(&SourceType::WebDAV));
assert!(batches.contains_key(&SourceType::LocalFolder));
assert!(batches.contains_key(&SourceType::S3));
// WebDAV should have 2 sources
assert_eq!(batches[&SourceType::WebDAV].len(), 2);
assert_eq!(batches[&SourceType::LocalFolder].len(), 1);
assert_eq!(batches[&SourceType::S3].len(), 1);
}
use std::collections::HashMap;
fn group_sources_by_type(sources: &[Source]) -> HashMap<SourceType, Vec<&Source>> {
let mut groups: HashMap<SourceType, Vec<&Source>> = HashMap::new();
for source in sources {
groups.entry(source.source_type.clone()).or_insert_with(Vec::new).push(source);
}
groups
}
#[test]
fn test_resume_failure_handling() {
let user_id = Uuid::new_v4();
let source = create_interrupted_source(user_id, SourceType::WebDAV);
// Test different failure scenarios
let failure_scenarios = vec![
ResumeFailure::NetworkTimeout,
ResumeFailure::AuthenticationError,
ResumeFailure::SourceNotFound,
ResumeFailure::ConfigurationError,
ResumeFailure::InternalError,
];
for failure in failure_scenarios {
let should_retry = should_retry_resume_failure(&failure);
let retry_delay = get_retry_delay_for_failure(&failure);
match failure {
ResumeFailure::NetworkTimeout => {
assert!(should_retry, "Should retry network timeouts");
assert!(retry_delay > Duration::ZERO, "Should have retry delay");
},
ResumeFailure::AuthenticationError => {
assert!(!should_retry, "Should not retry auth errors");
},
ResumeFailure::SourceNotFound => {
assert!(!should_retry, "Should not retry if source not found");
},
ResumeFailure::ConfigurationError => {
assert!(!should_retry, "Should not retry config errors");
},
ResumeFailure::InternalError => {
assert!(should_retry, "Should retry internal errors");
},
}
}
}
#[derive(Debug, Clone)]
enum ResumeFailure {
NetworkTimeout,
AuthenticationError,
SourceNotFound,
ConfigurationError,
InternalError,
}
fn should_retry_resume_failure(failure: &ResumeFailure) -> bool {
match failure {
ResumeFailure::NetworkTimeout => true,
ResumeFailure::InternalError => true,
_ => false,
}
}
fn get_retry_delay_for_failure(failure: &ResumeFailure) -> Duration {
match failure {
ResumeFailure::NetworkTimeout => Duration::from_secs(30),
ResumeFailure::InternalError => Duration::from_secs(60),
_ => Duration::ZERO,
}
}
#[tokio::test]
async fn test_resume_state_persistence() {
let state = create_test_app_state().await;
let user_id = Uuid::new_v4();
// Create a source that appears interrupted
let interrupted_source = create_interrupted_source(user_id, SourceType::WebDAV);
// Test that we can track resume progress
let mut resume_state = ResumeState::new();
resume_state.start_resume(&interrupted_source);
assert!(resume_state.is_resuming(&interrupted_source.id));
resume_state.complete_resume(&interrupted_source.id, 15);
assert!(!resume_state.is_resuming(&interrupted_source.id));
let stats = resume_state.get_stats(&interrupted_source.id);
assert!(stats.is_some());
assert_eq!(stats.unwrap().files_processed, 15);
}
#[derive(Debug)]
struct ResumeState {
active_resumes: HashMap<Uuid, SystemTime>,
completed_resumes: HashMap<Uuid, ResumeStats>,
}
impl ResumeState {
fn new() -> Self {
Self {
active_resumes: HashMap::new(),
completed_resumes: HashMap::new(),
}
}
fn start_resume(&mut self, source: &Source) {
self.active_resumes.insert(source.id, SystemTime::now());
}
fn is_resuming(&self, source_id: &Uuid) -> bool {
self.active_resumes.contains_key(source_id)
}
fn complete_resume(&mut self, source_id: &Uuid, files_processed: u32) {
if let Some(start_time) = self.active_resumes.remove(source_id) {
let duration = SystemTime::now().duration_since(start_time).unwrap_or(Duration::ZERO);
self.completed_resumes.insert(*source_id, ResumeStats {
files_processed,
duration,
completed_at: SystemTime::now(),
});
}
}
fn get_stats(&self, source_id: &Uuid) -> Option<&ResumeStats> {
self.completed_resumes.get(source_id)
}
}
#[derive(Debug, Clone)]
struct ResumeStats {
files_processed: u32,
duration: Duration,
completed_at: SystemTime,
}
+367
View File
@@ -0,0 +1,367 @@
/*!
* Basic Sync Unit Tests
*
* Simple tests for sync functionality that don't require database connection
*/
use serde_json::json;
use uuid::Uuid;
use chrono::Utc;
use readur::models::{SourceType, SourceStatus, WebDAVSourceConfig, LocalFolderSourceConfig, S3SourceConfig};
#[test]
fn test_source_type_string_conversion() {
assert_eq!(SourceType::WebDAV.to_string(), "webdav");
assert_eq!(SourceType::LocalFolder.to_string(), "local_folder");
assert_eq!(SourceType::S3.to_string(), "s3");
}
#[test]
fn test_source_status_string_conversion() {
assert_eq!(SourceStatus::Idle.to_string(), "idle");
assert_eq!(SourceStatus::Syncing.to_string(), "syncing");
assert_eq!(SourceStatus::Error.to_string(), "error");
}
#[test]
fn test_webdav_config_serialization() {
let config = 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()),
};
let json_value = serde_json::to_value(&config).unwrap();
let deserialized: WebDAVSourceConfig = serde_json::from_value(json_value).unwrap();
assert_eq!(config.server_url, deserialized.server_url);
assert_eq!(config.username, deserialized.username);
assert_eq!(config.auto_sync, deserialized.auto_sync);
assert_eq!(config.sync_interval_minutes, deserialized.sync_interval_minutes);
}
#[test]
fn test_local_folder_config_serialization() {
let config = LocalFolderSourceConfig {
watch_folders: vec!["/home/user/documents".to_string()],
file_extensions: vec![".pdf".to_string(), ".txt".to_string(), ".jpg".to_string()],
auto_sync: true,
sync_interval_minutes: 30,
recursive: true,
follow_symlinks: false,
};
let json_value = serde_json::to_value(&config).unwrap();
let deserialized: LocalFolderSourceConfig = serde_json::from_value(json_value).unwrap();
assert_eq!(config.watch_folders, deserialized.watch_folders);
assert_eq!(config.recursive, deserialized.recursive);
assert_eq!(config.follow_symlinks, deserialized.follow_symlinks);
assert_eq!(config.sync_interval_minutes, deserialized.sync_interval_minutes);
}
#[test]
fn test_s3_config_serialization() {
let config = S3SourceConfig {
bucket_name: "test-documents".to_string(),
region: "us-east-1".to_string(),
access_key_id: "AKIATEST".to_string(),
secret_access_key: "secrettest".to_string(),
endpoint_url: Some("https://minio.example.com".to_string()),
prefix: Some("documents/".to_string()),
watch_folders: vec!["documents/".to_string()],
file_extensions: vec![".pdf".to_string(), ".docx".to_string()],
auto_sync: true,
sync_interval_minutes: 120,
};
let json_value = serde_json::to_value(&config).unwrap();
let deserialized: S3SourceConfig = serde_json::from_value(json_value).unwrap();
assert_eq!(config.bucket_name, deserialized.bucket_name);
assert_eq!(config.region, deserialized.region);
assert_eq!(config.endpoint_url, deserialized.endpoint_url);
assert_eq!(config.prefix, deserialized.prefix);
assert_eq!(config.sync_interval_minutes, deserialized.sync_interval_minutes);
}
#[test]
fn test_auto_sync_validation() {
// Test that auto_sync works with different intervals
let intervals = vec![1, 15, 30, 60, 120, 240, 480];
for interval in intervals {
let webdav_config = WebDAVSourceConfig {
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()],
auto_sync: true,
sync_interval_minutes: interval,
server_type: Some("nextcloud".to_string()),
};
assert!(webdav_config.auto_sync);
assert_eq!(webdav_config.sync_interval_minutes, interval);
assert!(webdav_config.sync_interval_minutes > 0);
}
}
#[test]
fn test_file_extension_validation() {
let valid_extensions = vec![".pdf", ".txt", ".jpg", ".png", ".docx", ".xlsx"];
let invalid_extensions = vec!["pdf", "txt", "", "no-dot", ".", ".."];
for ext in valid_extensions {
assert!(ext.starts_with('.'), "Extension should start with dot: {}", ext);
assert!(ext.len() > 1, "Extension should have content after dot: {}", ext);
}
// Test in actual config
let config = WebDAVSourceConfig {
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(), ".txt".to_string()],
auto_sync: true,
sync_interval_minutes: 60,
server_type: Some("nextcloud".to_string()),
};
for ext in &config.file_extensions {
assert!(ext.starts_with('.'));
assert!(ext.len() > 1);
}
}
#[test]
fn test_watch_folder_validation() {
let valid_folders = vec!["/", "/home", "/home/user", "/Documents", "/var/log"];
let questionable_folders = vec!["", "relative/path", "../parent"];
for folder in valid_folders {
let config = LocalFolderSourceConfig {
watch_folders: vec![folder.to_string()],
file_extensions: vec![".pdf".to_string()],
auto_sync: true,
sync_interval_minutes: 30,
recursive: true,
follow_symlinks: false,
};
assert_eq!(config.watch_folders[0], folder);
if folder.starts_with('/') {
assert!(folder.len() >= 1);
}
}
}
#[test]
fn test_server_type_validation() {
let valid_server_types = vec![
Some("nextcloud".to_string()),
Some("owncloud".to_string()),
Some("generic".to_string()),
None,
];
for server_type in valid_server_types {
let config = WebDAVSourceConfig {
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()],
auto_sync: true,
sync_interval_minutes: 60,
server_type: server_type.clone(),
};
assert_eq!(config.server_type, server_type);
}
}
#[test]
fn test_s3_bucket_name_validation() {
let valid_bucket_names = vec![
"test-bucket",
"my-bucket-123",
"bucket.with.dots",
"a", // minimum length
];
let invalid_bucket_names = vec![
"", // empty
"Bucket", // uppercase
"bucket_with_underscores", // underscores
"bucket with spaces", // spaces
];
for bucket_name in valid_bucket_names {
let config = S3SourceConfig {
bucket_name: bucket_name.to_string(),
region: "us-east-1".to_string(),
access_key_id: "test".to_string(),
secret_access_key: "test".to_string(),
endpoint_url: None,
prefix: None,
watch_folders: vec!["".to_string()],
file_extensions: vec![".pdf".to_string()],
auto_sync: true,
sync_interval_minutes: 120,
};
assert_eq!(config.bucket_name, bucket_name);
// Basic validation rules
assert!(!config.bucket_name.is_empty());
assert!(config.bucket_name.len() <= 63); // AWS limit
}
}
#[test]
fn test_endpoint_url_handling() {
// Test AWS S3 (no endpoint)
let aws_config = S3SourceConfig {
bucket_name: "test-bucket".to_string(),
region: "us-east-1".to_string(),
access_key_id: "AKIA...".to_string(),
secret_access_key: "secret".to_string(),
endpoint_url: None, // AWS S3
prefix: None,
watch_folders: vec!["".to_string()],
file_extensions: vec![".pdf".to_string()],
auto_sync: true,
sync_interval_minutes: 120,
};
assert!(aws_config.endpoint_url.is_none());
// Test MinIO (custom endpoint)
let minio_config = S3SourceConfig {
bucket_name: "test-bucket".to_string(),
region: "us-east-1".to_string(),
access_key_id: "minioadmin".to_string(),
secret_access_key: "minioadmin".to_string(),
endpoint_url: Some("https://minio.example.com".to_string()),
prefix: None,
watch_folders: vec!["".to_string()],
file_extensions: vec![".pdf".to_string()],
auto_sync: true,
sync_interval_minutes: 120,
};
assert!(minio_config.endpoint_url.is_some());
assert!(minio_config.endpoint_url.unwrap().starts_with("https://"));
}
#[test]
fn test_sync_interval_ranges() {
// Test reasonable sync intervals
let intervals = vec![
(1, "Very frequent"),
(5, "Frequent"),
(15, "Every 15 minutes"),
(30, "Half hourly"),
(60, "Hourly"),
(120, "Every 2 hours"),
(240, "Every 4 hours"),
(480, "Every 8 hours"),
(1440, "Daily"),
];
for (interval, description) in intervals {
let config = WebDAVSourceConfig {
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()],
auto_sync: true,
sync_interval_minutes: interval,
server_type: Some("nextcloud".to_string()),
};
assert_eq!(config.sync_interval_minutes, interval);
assert!(config.sync_interval_minutes > 0, "Interval should be positive for: {}", description);
assert!(config.sync_interval_minutes <= 1440, "Interval should be at most daily for: {}", description);
}
}
#[test]
fn test_configuration_size_limits() {
// Test that configurations don't become too large when serialized
let large_webdav_config = WebDAVSourceConfig {
server_url: "https://very-long-server-name-that-might-be-used-in-enterprise.example.com".to_string(),
username: "very_long_username_that_might_exist".to_string(),
password: "very_long_password_with_special_chars_!@#$%^&*()".to_string(),
watch_folders: vec![
"/very/long/path/to/documents/folder/one".to_string(),
"/very/long/path/to/documents/folder/two".to_string(),
"/very/long/path/to/documents/folder/three".to_string(),
],
file_extensions: vec![
".pdf".to_string(), ".txt".to_string(), ".doc".to_string(),
".docx".to_string(), ".xls".to_string(), ".xlsx".to_string(),
".ppt".to_string(), ".pptx".to_string(), ".jpg".to_string(),
".png".to_string(), ".gif".to_string(), ".bmp".to_string(),
],
auto_sync: true,
sync_interval_minutes: 60,
server_type: Some("nextcloud".to_string()),
};
let serialized = serde_json::to_string(&large_webdav_config).unwrap();
// Reasonable size limit for configuration
assert!(serialized.len() < 2048, "Configuration should not be too large: {} bytes", serialized.len());
assert!(serialized.len() > 100, "Configuration should have substantial content");
// Test that it can be deserialized back
let _deserialized: WebDAVSourceConfig = serde_json::from_str(&serialized).unwrap();
}
#[test]
fn test_concurrent_configuration_access() {
use std::sync::Arc;
use std::thread;
let config = Arc::new(WebDAVSourceConfig {
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()],
auto_sync: true,
sync_interval_minutes: 60,
server_type: Some("nextcloud".to_string()),
});
let mut handles = vec![];
// Spawn multiple threads that read the configuration
for i in 0..5 {
let config_clone = Arc::clone(&config);
let handle = thread::spawn(move || {
// Simulate concurrent access
for _ in 0..100 {
assert_eq!(config_clone.server_url, "https://test.com");
assert_eq!(config_clone.sync_interval_minutes, 60);
assert!(config_clone.auto_sync);
}
i // Return thread id
});
handles.push(handle);
}
// Wait for all threads and collect results
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(results, vec![0, 1, 2, 3, 4]);
}
+764
View File
@@ -0,0 +1,764 @@
/*!
* Sync Cancellation Behavior Unit Tests
*
* Tests for sync cancellation functionality including:
* - Graceful cancellation of ongoing downloads
* - Allowing OCR to continue after sync cancellation
* - Cleanup of partial downloads
* - State management during cancellation
* - Cancellation signal propagation
* - Resource cleanup and memory management
*/
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
use std::time::{Duration, SystemTime, Instant};
use std::thread;
use uuid::Uuid;
use chrono::Utc;
use serde_json::json;
use tokio::time::{sleep, timeout};
use tokio::sync::mpsc;
use readur::{
AppState,
config::Config,
db::Database,
models::{Source, SourceType, SourceStatus, WebDAVSourceConfig},
source_scheduler::SourceScheduler,
};
/// Create a test app state
async fn create_test_app_state() -> Arc<AppState> {
let config = Config {
database_url: "sqlite::memory:".to_string(),
server_address: "127.0.0.1:8080".to_string(),
jwt_secret: "test_secret".to_string(),
upload_dir: "/tmp/test_uploads".to_string(),
max_file_size: 10 * 1024 * 1024,
};
let db = Database::new(&config.database_url).await.unwrap();
Arc::new(AppState {
db,
config,
webdav_scheduler: None,
source_scheduler: None,
})
}
/// Create a test source for cancellation testing
fn create_test_source_for_cancellation(user_id: Uuid) -> Source {
Source {
id: Uuid::new_v4(),
user_id,
name: "Cancellable Test Source".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://cloud.example.com",
"username": "testuser",
"password": "testpass",
"watch_folders": ["/Documents"],
"file_extensions": [".pdf", ".txt", ".jpg"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
}),
status: SourceStatus::Syncing,
last_sync_at: Some(Utc::now()),
last_error: None,
last_error_at: None,
total_files_synced: 10,
total_files_pending: 25, // Many files still to sync
total_size_bytes: 100_000_000, // 100MB
created_at: Utc::now() - chrono::Duration::hours(1),
updated_at: Utc::now(),
}
}
#[tokio::test]
async fn test_cancellation_token_basic() {
// Test basic cancellation token functionality
let cancellation_token = Arc::new(AtomicBool::new(false));
// Initially not cancelled
assert!(!cancellation_token.load(Ordering::Relaxed));
// Cancel the operation
cancellation_token.store(true, Ordering::Relaxed);
assert!(cancellation_token.load(Ordering::Relaxed));
// Test that cancelled operations should stop
let should_continue = !cancellation_token.load(Ordering::Relaxed);
assert!(!should_continue, "Cancelled operation should not continue");
}
#[tokio::test]
async fn test_graceful_download_cancellation() {
let cancellation_token = Arc::new(AtomicBool::new(false));
let download_progress = Arc::new(Mutex::new(DownloadProgress::new()));
// Simulate download process
let token_clone = Arc::clone(&cancellation_token);
let progress_clone = Arc::clone(&download_progress);
let download_handle = tokio::spawn(async move {
simulate_download_with_cancellation(token_clone, progress_clone).await
});
// Let download start
sleep(Duration::from_millis(50)).await;
// Cancel after some progress
cancellation_token.store(true, Ordering::Relaxed);
let result = download_handle.await.unwrap();
// Check that download was cancelled gracefully
assert!(result.was_cancelled, "Download should be marked as cancelled");
assert!(result.bytes_downloaded > 0, "Some progress should have been made");
assert!(result.bytes_downloaded < result.total_bytes, "Download should not be complete");
let final_progress = download_progress.lock().unwrap();
assert!(final_progress.files_downloaded < final_progress.total_files);
}
async fn simulate_download_with_cancellation(
cancellation_token: Arc<AtomicBool>,
progress: Arc<Mutex<DownloadProgress>>,
) -> DownloadResult {
let total_files = 10;
let file_size = 1024 * 1024; // 1MB per file
let mut bytes_downloaded = 0;
let mut files_downloaded = 0;
for i in 0..total_files {
// Check cancellation before each file
if cancellation_token.load(Ordering::Relaxed) {
return DownloadResult {
was_cancelled: true,
bytes_downloaded,
total_bytes: total_files * file_size,
files_downloaded,
total_files,
};
}
// Simulate file download
sleep(Duration::from_millis(20)).await;
bytes_downloaded += file_size;
files_downloaded += 1;
// Update progress
{
let mut prog = progress.lock().unwrap();
prog.files_downloaded = files_downloaded;
prog.bytes_downloaded = bytes_downloaded;
}
}
DownloadResult {
was_cancelled: false,
bytes_downloaded,
total_bytes: total_files * file_size,
files_downloaded,
total_files,
}
}
#[derive(Debug, Clone)]
struct DownloadProgress {
files_downloaded: u32,
bytes_downloaded: u64,
total_files: u32,
total_bytes: u64,
}
impl DownloadProgress {
fn new() -> Self {
Self {
files_downloaded: 0,
bytes_downloaded: 0,
total_files: 100,
total_bytes: 100 * 1024 * 1024, // 100MB
}
}
}
#[derive(Debug)]
struct DownloadResult {
was_cancelled: bool,
bytes_downloaded: u64,
total_bytes: u64,
files_downloaded: u32,
total_files: u32,
}
#[tokio::test]
async fn test_ocr_continues_after_sync_cancellation() {
let sync_cancellation_token = Arc::new(AtomicBool::new(false));
let ocr_queue = Arc::new(Mutex::new(OcrQueue::new()));
// Add some files to OCR queue before cancellation
{
let mut queue = ocr_queue.lock().unwrap();
queue.add_job(OcrJob { id: Uuid::new_v4(), file_path: "doc1.pdf".to_string() });
queue.add_job(OcrJob { id: Uuid::new_v4(), file_path: "doc2.pdf".to_string() });
queue.add_job(OcrJob { id: Uuid::new_v4(), file_path: "doc3.pdf".to_string() });
}
// Start OCR processing (should continue even after sync cancellation)
let ocr_queue_clone = Arc::clone(&ocr_queue);
let ocr_handle = tokio::spawn(async move {
process_ocr_queue(ocr_queue_clone).await
});
// Cancel sync (should not affect OCR)
sync_cancellation_token.store(true, Ordering::Relaxed);
// Let OCR process for a bit
sleep(Duration::from_millis(200)).await;
// Check that OCR continued processing
let queue_state = ocr_queue.lock().unwrap();
assert!(queue_state.processed_jobs > 0, "OCR should have processed jobs despite sync cancellation");
// Stop OCR processing
queue_state.stop();
drop(queue_state);
let _ = ocr_handle.await;
}
#[derive(Debug, Clone)]
struct OcrJob {
id: Uuid,
file_path: String,
}
struct OcrQueue {
pending_jobs: Vec<OcrJob>,
processed_jobs: u32,
is_stopped: Arc<AtomicBool>,
}
impl OcrQueue {
fn new() -> Self {
Self {
pending_jobs: Vec::new(),
processed_jobs: 0,
is_stopped: Arc::new(AtomicBool::new(false)),
}
}
fn add_job(&mut self, job: OcrJob) {
self.pending_jobs.push(job);
}
fn stop(&self) {
self.is_stopped.store(true, Ordering::Relaxed);
}
fn is_stopped(&self) -> bool {
self.is_stopped.load(Ordering::Relaxed)
}
}
async fn process_ocr_queue(ocr_queue: Arc<Mutex<OcrQueue>>) {
loop {
let should_stop = {
let queue = ocr_queue.lock().unwrap();
queue.is_stopped()
};
if should_stop {
break;
}
// Process next job if available
let job = {
let mut queue = ocr_queue.lock().unwrap();
queue.pending_jobs.pop()
};
if let Some(job) = job {
// Simulate OCR processing
sleep(Duration::from_millis(50)).await;
let mut queue = ocr_queue.lock().unwrap();
queue.processed_jobs += 1;
} else {
// No jobs available, wait a bit
sleep(Duration::from_millis(10)).await;
}
}
}
#[tokio::test]
async fn test_partial_download_cleanup() {
let temp_files = Arc::new(Mutex::new(Vec::new()));
let cancellation_token = Arc::new(AtomicBool::new(false));
// Simulate creating temporary files during download
let temp_files_clone = Arc::clone(&temp_files);
let token_clone = Arc::clone(&cancellation_token);
let download_handle = tokio::spawn(async move {
simulate_download_with_temp_files(token_clone, temp_files_clone).await
});
// Let some temp files be created
sleep(Duration::from_millis(100)).await;
// Cancel the download
cancellation_token.store(true, Ordering::Relaxed);
let result = download_handle.await.unwrap();
assert!(result.was_cancelled);
// Check that temporary files were cleaned up
let temp_files = temp_files.lock().unwrap();
for temp_file in temp_files.iter() {
assert!(!temp_file.exists, "Temporary file should be cleaned up: {}", temp_file.path);
}
}
async fn simulate_download_with_temp_files(
cancellation_token: Arc<AtomicBool>,
temp_files: Arc<Mutex<Vec<TempFile>>>,
) -> DownloadResult {
let total_files = 5;
let mut files_downloaded = 0;
for i in 0..total_files {
if cancellation_token.load(Ordering::Relaxed) {
// Cleanup temp files on cancellation
cleanup_temp_files(&temp_files).await;
return DownloadResult {
was_cancelled: true,
bytes_downloaded: files_downloaded * 1024,
total_bytes: total_files * 1024,
files_downloaded,
total_files,
};
}
// Create temp file
let temp_file = TempFile {
path: format!("/tmp/download_{}.tmp", i),
exists: true,
};
temp_files.lock().unwrap().push(temp_file);
// Simulate download
sleep(Duration::from_millis(50)).await;
files_downloaded += 1;
}
DownloadResult {
was_cancelled: false,
bytes_downloaded: files_downloaded * 1024,
total_bytes: total_files * 1024,
files_downloaded,
total_files,
}
}
async fn cleanup_temp_files(temp_files: &Arc<Mutex<Vec<TempFile>>>) {
let mut files = temp_files.lock().unwrap();
for file in files.iter_mut() {
file.exists = false; // Simulate file deletion
}
}
#[derive(Debug, Clone)]
struct TempFile {
path: String,
exists: bool,
}
#[test]
fn test_cancellation_signal_propagation() {
use std::sync::mpsc;
// Test that cancellation signals propagate through the system
let (cancel_sender, cancel_receiver) = mpsc::channel();
let (progress_sender, progress_receiver) = mpsc::channel();
// Simulate worker thread
let worker_handle = thread::spawn(move || {
let mut work_done = 0;
loop {
// Check for cancellation
if let Ok(_) = cancel_receiver.try_recv() {
progress_sender.send(WorkerMessage::Cancelled(work_done)).unwrap();
break;
}
// Do some work
work_done += 1;
progress_sender.send(WorkerMessage::Progress(work_done)).unwrap();
if work_done >= 10 {
progress_sender.send(WorkerMessage::Completed(work_done)).unwrap();
break;
}
thread::sleep(Duration::from_millis(50));
}
});
// Let worker do some work
thread::sleep(Duration::from_millis(150));
// Send cancellation signal
cancel_sender.send(()).unwrap();
// Wait for worker to finish
worker_handle.join().unwrap();
// Check final message
let mut messages = Vec::new();
while let Ok(msg) = progress_receiver.try_recv() {
messages.push(msg);
}
assert!(!messages.is_empty(), "Should receive progress messages");
// Last message should be cancellation
if let Some(last_msg) = messages.last() {
match last_msg {
WorkerMessage::Cancelled(work_done) => {
assert!(*work_done > 0, "Some work should have been done before cancellation");
assert!(*work_done < 10, "Work should not have completed");
},
_ => panic!("Expected cancellation message, got: {:?}", last_msg),
}
}
}
#[derive(Debug, Clone)]
enum WorkerMessage {
Progress(u32),
Completed(u32),
Cancelled(u32),
}
#[tokio::test]
async fn test_cancellation_timeout() {
let cancellation_token = Arc::new(AtomicBool::new(false));
let slow_operation_started = Arc::new(AtomicBool::new(false));
let token_clone = Arc::clone(&cancellation_token);
let started_clone = Arc::clone(&slow_operation_started);
// Start a slow operation that doesn't check cancellation frequently
let slow_handle = tokio::spawn(async move {
started_clone.store(true, Ordering::Relaxed);
// Simulate slow operation that takes time to respond to cancellation
for _ in 0..20 {
sleep(Duration::from_millis(100)).await;
// Only check cancellation every few iterations (slow response)
if token_clone.load(Ordering::Relaxed) {
return "cancelled".to_string();
}
}
"completed".to_string()
});
// Wait for operation to start
while !slow_operation_started.load(Ordering::Relaxed) {
sleep(Duration::from_millis(10)).await;
}
// Cancel after a short time
sleep(Duration::from_millis(150)).await;
cancellation_token.store(true, Ordering::Relaxed);
// Set a timeout for the cancellation to take effect
let result = timeout(Duration::from_secs(3), slow_handle).await;
match result {
Ok(Ok(status)) => {
assert_eq!(status, "cancelled", "Operation should have been cancelled");
},
Ok(Err(e)) => panic!("Operation failed: {:?}", e),
Err(_) => panic!("Operation did not respond to cancellation within timeout"),
}
}
#[tokio::test]
async fn test_resource_cleanup_on_cancellation() {
let resources = Arc::new(Mutex::new(ResourceTracker::new()));
let cancellation_token = Arc::new(AtomicBool::new(false));
let resources_clone = Arc::clone(&resources);
let token_clone = Arc::clone(&cancellation_token);
let work_handle = tokio::spawn(async move {
simulate_work_with_resources(token_clone, resources_clone).await
});
// Let work allocate some resources
sleep(Duration::from_millis(100)).await;
// Check that resources were allocated
{
let tracker = resources.lock().unwrap();
assert!(tracker.allocated_resources > 0, "Resources should be allocated");
}
// Cancel the work
cancellation_token.store(true, Ordering::Relaxed);
let result = work_handle.await.unwrap();
assert!(result.was_cancelled, "Work should be cancelled");
// Check that resources were cleaned up
{
let tracker = resources.lock().unwrap();
assert_eq!(tracker.allocated_resources, 0, "All resources should be cleaned up");
}
}
async fn simulate_work_with_resources(
cancellation_token: Arc<AtomicBool>,
resources: Arc<Mutex<ResourceTracker>>,
) -> WorkResult {
let mut allocated_count = 0;
for i in 0..10 {
if cancellation_token.load(Ordering::Relaxed) {
// Cleanup resources on cancellation
cleanup_resources(&resources, allocated_count).await;
return WorkResult {
was_cancelled: true,
work_completed: i,
};
}
// Allocate a resource
{
let mut tracker = resources.lock().unwrap();
tracker.allocate_resource();
allocated_count += 1;
}
sleep(Duration::from_millis(50)).await;
}
// Normal completion - cleanup resources
cleanup_resources(&resources, allocated_count).await;
WorkResult {
was_cancelled: false,
work_completed: 10,
}
}
async fn cleanup_resources(resources: &Arc<Mutex<ResourceTracker>>, count: u32) {
let mut tracker = resources.lock().unwrap();
for _ in 0..count {
tracker.deallocate_resource();
}
}
#[derive(Debug)]
struct ResourceTracker {
allocated_resources: u32,
}
impl ResourceTracker {
fn new() -> Self {
Self {
allocated_resources: 0,
}
}
fn allocate_resource(&mut self) {
self.allocated_resources += 1;
}
fn deallocate_resource(&mut self) {
if self.allocated_resources > 0 {
self.allocated_resources -= 1;
}
}
}
#[derive(Debug)]
struct WorkResult {
was_cancelled: bool,
work_completed: u32,
}
#[test]
fn test_cancellation_state_transitions() {
// Test valid state transitions during cancellation
let test_cases = vec![
(SourceStatus::Syncing, CancellationState::Requested, SourceStatus::Syncing),
(SourceStatus::Syncing, CancellationState::InProgress, SourceStatus::Syncing),
(SourceStatus::Syncing, CancellationState::Completed, SourceStatus::Idle),
];
for (initial_status, cancellation_state, expected_final_status) in test_cases {
let final_status = apply_cancellation_state_transition(initial_status, cancellation_state);
assert_eq!(final_status, expected_final_status,
"Wrong final status for cancellation state: {:?}", cancellation_state);
}
}
#[derive(Debug, Clone)]
enum CancellationState {
Requested,
InProgress,
Completed,
}
fn apply_cancellation_state_transition(
current_status: SourceStatus,
cancellation_state: CancellationState,
) -> SourceStatus {
match (current_status, cancellation_state) {
(SourceStatus::Syncing, CancellationState::Completed) => SourceStatus::Idle,
(status, _) => status, // Other transitions don't change status
}
}
#[tokio::test]
async fn test_concurrent_cancellation_requests() {
use std::sync::atomic::AtomicU32;
let cancellation_counter = Arc::new(AtomicU32::new(0));
let work_in_progress = Arc::new(AtomicBool::new(true));
let counter_clone = Arc::clone(&cancellation_counter);
let work_clone = Arc::clone(&work_in_progress);
// Start work that will receive cancellation
let work_handle = tokio::spawn(async move {
while work_clone.load(Ordering::Relaxed) {
sleep(Duration::from_millis(10)).await;
}
counter_clone.load(Ordering::Relaxed)
});
// Send multiple concurrent cancellation requests
let mut cancel_handles = Vec::new();
for _ in 0..5 {
let counter = Arc::clone(&cancellation_counter);
let work = Arc::clone(&work_in_progress);
let handle = tokio::spawn(async move {
// Simulate cancellation request
sleep(Duration::from_millis(50)).await;
let count = counter.fetch_add(1, Ordering::Relaxed);
if count == 0 {
// First cancellation request should stop the work
work.store(false, Ordering::Relaxed);
}
});
cancel_handles.push(handle);
}
// Wait for all cancellation requests
for handle in cancel_handles {
handle.await.unwrap();
}
// Wait for work to complete
let final_count = work_handle.await.unwrap();
// Should have received exactly 5 cancellation requests
assert_eq!(final_count, 5, "Should receive all cancellation requests");
// Work should be stopped
assert!(!work_in_progress.load(Ordering::Relaxed), "Work should be stopped");
}
#[test]
fn test_cancellation_reason_tracking() {
// Test tracking different reasons for cancellation
let cancellation_reasons = vec![
CancellationReason::UserRequested,
CancellationReason::ServerShutdown,
CancellationReason::NetworkError,
CancellationReason::Timeout,
CancellationReason::ResourceExhaustion,
];
for reason in cancellation_reasons {
let should_retry = should_retry_after_cancellation(&reason);
let cleanup_priority = get_cleanup_priority(&reason);
match reason {
CancellationReason::UserRequested => {
assert!(!should_retry, "User-requested cancellation should not retry");
assert_eq!(cleanup_priority, CleanupPriority::Low);
},
CancellationReason::ServerShutdown => {
assert!(!should_retry, "Server shutdown should not retry");
assert_eq!(cleanup_priority, CleanupPriority::High);
},
CancellationReason::NetworkError => {
assert!(should_retry, "Network errors should retry");
assert_eq!(cleanup_priority, CleanupPriority::Medium);
},
CancellationReason::Timeout => {
assert!(should_retry, "Timeouts should retry");
assert_eq!(cleanup_priority, CleanupPriority::Medium);
},
CancellationReason::ResourceExhaustion => {
assert!(should_retry, "Resource exhaustion should retry later");
assert_eq!(cleanup_priority, CleanupPriority::High);
},
}
}
}
#[derive(Debug, Clone)]
enum CancellationReason {
UserRequested,
ServerShutdown,
NetworkError,
Timeout,
ResourceExhaustion,
}
#[derive(Debug, Clone, PartialEq)]
enum CleanupPriority {
Low,
Medium,
High,
}
fn should_retry_after_cancellation(reason: &CancellationReason) -> bool {
match reason {
CancellationReason::UserRequested => false,
CancellationReason::ServerShutdown => false,
CancellationReason::NetworkError => true,
CancellationReason::Timeout => true,
CancellationReason::ResourceExhaustion => true,
}
}
fn get_cleanup_priority(reason: &CancellationReason) -> CleanupPriority {
match reason {
CancellationReason::UserRequested => CleanupPriority::Low,
CancellationReason::ServerShutdown => CleanupPriority::High,
CancellationReason::NetworkError => CleanupPriority::Medium,
CancellationReason::Timeout => CleanupPriority::Medium,
CancellationReason::ResourceExhaustion => CleanupPriority::High,
}
}
+552
View File
@@ -0,0 +1,552 @@
/*!
* 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 {
paths: 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.paths.len(), 2);
assert_eq!(config.paths[0], "/test/documents");
assert_eq!(config.paths[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.paths.is_empty(), "Should have at least one path");
for path in &config.paths {
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 {
paths: 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.paths[0], "/this/path/does/not/exist");
// Empty paths
let empty_paths_config = LocalFolderSourceConfig {
paths: Vec::new(),
recursive: true,
follow_symlinks: false,
auto_sync: true,
sync_interval_minutes: 30,
file_extensions: vec![".txt".to_string()],
};
assert!(empty_paths_config.paths.is_empty());
// Invalid sync interval
let invalid_interval_config = LocalFolderSourceConfig {
paths: 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));
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;
if let Ok(entries) = fs::read_dir(&*base_path) {
for entry in entries {
if let Ok(entry) = entry {
if entry.file_type().unwrap().is_file() {
local_count += 1;
}
}
}
}
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");
}
+602
View File
@@ -0,0 +1,602 @@
/*!
* Manual Sync Triggering Unit Tests
*
* Tests for manual sync triggering functionality including:
* - API endpoint testing
* - Source status validation
* - Conflict detection (already syncing)
* - Permission and authentication checks
* - Error handling and recovery
* - Integration with source scheduler
*/
use std::sync::Arc;
use uuid::Uuid;
use chrono::Utc;
use serde_json::json;
use axum::http::StatusCode;
use readur::{
AppState,
config::Config,
db::Database,
models::{Source, SourceType, SourceStatus, WebDAVSourceConfig, AuthUser, User, UserRole},
routes::sources,
};
/// Create a test app state
async fn create_test_app_state() -> Arc<AppState> {
let config = Config {
database_url: "sqlite::memory:".to_string(),
server_address: "127.0.0.1:8080".to_string(),
jwt_secret: "test_secret".to_string(),
upload_dir: "/tmp/test_uploads".to_string(),
max_file_size: 10 * 1024 * 1024,
};
let db = Database::new(&config.database_url).await.unwrap();
Arc::new(AppState {
db,
config,
webdav_scheduler: None,
source_scheduler: None,
})
}
/// Create a test user
fn create_test_user() -> User {
User {
id: Uuid::new_v4(),
username: "testuser".to_string(),
email: "test@example.com".to_string(),
password_hash: "hashed_password".to_string(),
role: UserRole::User,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
/// Create a test source in various states
fn create_test_source_with_status(status: SourceStatus, user_id: Uuid) -> Source {
Source {
id: Uuid::new_v4(),
user_id,
name: "Test WebDAV Source".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://cloud.example.com",
"username": "testuser",
"password": "testpass",
"watch_folders": ["/Documents"],
"file_extensions": [".pdf", ".txt"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
}),
status,
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[tokio::test]
async fn test_manual_sync_trigger_idle_source() {
let state = create_test_app_state().await;
let user = create_test_user();
let source = create_test_source_with_status(SourceStatus::Idle, user.id);
// Test that idle source can be triggered for sync
let can_trigger = can_trigger_manual_sync(&source);
assert!(can_trigger, "Idle source should be available for manual sync");
// Test status update to syncing
let updated_status = SourceStatus::Syncing;
assert_ne!(source.status, updated_status);
assert!(is_valid_sync_trigger_transition(&source.status, &updated_status));
}
#[tokio::test]
async fn test_manual_sync_trigger_already_syncing() {
let state = create_test_app_state().await;
let user = create_test_user();
let source = create_test_source_with_status(SourceStatus::Syncing, user.id);
// Test that already syncing source cannot be triggered again
let can_trigger = can_trigger_manual_sync(&source);
assert!(!can_trigger, "Already syncing source should not allow manual sync");
// This should result in HTTP 409 Conflict
let expected_status = StatusCode::CONFLICT;
let result_status = get_expected_status_for_sync_trigger(&source);
assert_eq!(result_status, expected_status);
}
#[tokio::test]
async fn test_manual_sync_trigger_error_state() {
let state = create_test_app_state().await;
let user = create_test_user();
let mut source = create_test_source_with_status(SourceStatus::Error, user.id);
source.last_error = Some("Previous sync failed".to_string());
source.last_error_at = Some(Utc::now());
// Test that source in error state can be triggered (retry)
let can_trigger = can_trigger_manual_sync(&source);
assert!(can_trigger, "Source in error state should allow manual sync retry");
// Test status transition from error to syncing
assert!(is_valid_sync_trigger_transition(&source.status, &SourceStatus::Syncing));
}
fn can_trigger_manual_sync(source: &Source) -> bool {
match source.status {
SourceStatus::Idle => true,
SourceStatus::Error => true,
SourceStatus::Syncing => false,
}
}
fn is_valid_sync_trigger_transition(from: &SourceStatus, to: &SourceStatus) -> bool {
match (from, to) {
(SourceStatus::Idle, SourceStatus::Syncing) => true,
(SourceStatus::Error, SourceStatus::Syncing) => true,
_ => false,
}
}
fn get_expected_status_for_sync_trigger(source: &Source) -> StatusCode {
match source.status {
SourceStatus::Idle => StatusCode::OK,
SourceStatus::Error => StatusCode::OK,
SourceStatus::Syncing => StatusCode::CONFLICT,
}
}
#[tokio::test]
async fn test_source_ownership_validation() {
let user_1 = create_test_user();
let user_2 = User {
id: Uuid::new_v4(),
username: "otheruser".to_string(),
email: "other@example.com".to_string(),
password_hash: "other_hash".to_string(),
role: UserRole::User,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let source = create_test_source_with_status(SourceStatus::Idle, user_1.id);
// Test that owner can trigger sync
assert!(can_user_trigger_sync(&user_1, &source));
// Test that non-owner cannot trigger sync
assert!(!can_user_trigger_sync(&user_2, &source));
// Test admin can trigger any sync
let admin_user = User {
id: Uuid::new_v4(),
username: "admin".to_string(),
email: "admin@example.com".to_string(),
password_hash: "admin_hash".to_string(),
role: UserRole::Admin,
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert!(can_user_trigger_sync(&admin_user, &source));
}
fn can_user_trigger_sync(user: &User, source: &Source) -> bool {
user.role == UserRole::Admin || user.id == source.user_id
}
#[test]
fn test_sync_trigger_request_validation() {
// Test valid source IDs
let valid_id = Uuid::new_v4();
assert!(is_valid_source_id(&valid_id.to_string()));
// Test invalid source IDs
let invalid_ids = vec![
"",
"invalid-uuid",
"12345",
"not-a-uuid-at-all",
];
for invalid_id in invalid_ids {
assert!(!is_valid_source_id(invalid_id), "Should reject invalid UUID: {}", invalid_id);
}
}
fn is_valid_source_id(id_str: &str) -> bool {
Uuid::parse_str(id_str).is_ok()
}
#[test]
fn test_sync_trigger_rate_limiting() {
use std::collections::HashMap;
use std::time::{SystemTime, Duration};
// Test rate limiting for manual sync triggers
let mut rate_limiter = SyncRateLimiter::new();
let source_id = Uuid::new_v4();
// First trigger should be allowed
assert!(rate_limiter.can_trigger_sync(&source_id));
rate_limiter.record_sync_trigger(&source_id);
// Immediate second trigger should be blocked
assert!(!rate_limiter.can_trigger_sync(&source_id));
// After cooldown period, should be allowed again
rate_limiter.advance_time(Duration::from_secs(61)); // Advance past cooldown
assert!(rate_limiter.can_trigger_sync(&source_id));
}
struct SyncRateLimiter {
last_triggers: HashMap<Uuid, SystemTime>,
cooldown_period: Duration,
current_time: SystemTime,
}
impl SyncRateLimiter {
fn new() -> Self {
Self {
last_triggers: HashMap::new(),
cooldown_period: Duration::from_secs(60), // 1 minute cooldown
current_time: SystemTime::now(),
}
}
fn can_trigger_sync(&self, source_id: &Uuid) -> bool {
if let Some(&last_trigger) = self.last_triggers.get(source_id) {
self.current_time.duration_since(last_trigger).unwrap_or(Duration::ZERO) >= self.cooldown_period
} else {
true // Never triggered before
}
}
fn record_sync_trigger(&mut self, source_id: &Uuid) {
self.last_triggers.insert(*source_id, self.current_time);
}
fn advance_time(&mut self, duration: Duration) {
self.current_time += duration;
}
}
#[tokio::test]
async fn test_sync_trigger_with_disabled_source() {
let state = create_test_app_state().await;
let user = create_test_user();
let mut source = create_test_source_with_status(SourceStatus::Idle, user.id);
source.enabled = false; // Disable the source
// Test that disabled source cannot be triggered
let can_trigger = can_trigger_disabled_source(&source);
assert!(!can_trigger, "Disabled source should not allow manual sync");
// This should result in HTTP 400 Bad Request
let expected_status = if source.enabled {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
assert_eq!(expected_status, StatusCode::BAD_REQUEST);
}
fn can_trigger_disabled_source(source: &Source) -> bool {
source.enabled && can_trigger_manual_sync(source)
}
#[test]
fn test_sync_trigger_configuration_validation() {
let user_id = Uuid::new_v4();
// Test valid WebDAV configuration
let valid_source = create_test_source_with_status(SourceStatus::Idle, user_id);
let config_result: Result<WebDAVSourceConfig, _> = serde_json::from_value(valid_source.config.clone());
assert!(config_result.is_ok(), "Valid configuration should parse successfully");
// Test invalid configuration
let mut invalid_source = create_test_source_with_status(SourceStatus::Idle, user_id);
invalid_source.config = json!({
"server_url": "", // Invalid empty URL
"username": "test",
"password": "test"
// Missing required fields
});
let invalid_config_result: Result<WebDAVSourceConfig, _> = serde_json::from_value(invalid_source.config.clone());
assert!(invalid_config_result.is_err(), "Invalid configuration should fail to parse");
}
#[test]
fn test_concurrent_sync_trigger_protection() {
use std::sync::{Arc, Mutex};
use std::collections::HashSet;
use std::thread;
let active_syncs: Arc<Mutex<HashSet<Uuid>>> = Arc::new(Mutex::new(HashSet::new()));
let source_id = Uuid::new_v4();
let mut handles = vec![];
let results = Arc::new(Mutex::new(Vec::new()));
// Simulate multiple concurrent trigger attempts
for _ in 0..5 {
let active_syncs = Arc::clone(&active_syncs);
let results = Arc::clone(&results);
let handle = thread::spawn(move || {
let mut syncs = active_syncs.lock().unwrap();
let was_inserted = syncs.insert(source_id);
results.lock().unwrap().push(was_inserted);
// Simulate some work
std::thread::sleep(std::time::Duration::from_millis(10));
});
handles.push(handle);
}
// Wait for all threads
for handle in handles {
handle.join().unwrap();
}
let final_results = results.lock().unwrap();
let successful_triggers = final_results.iter().filter(|&&success| success).count();
// Only one thread should have successfully triggered the sync
assert_eq!(successful_triggers, 1, "Only one concurrent sync trigger should succeed");
}
#[test]
fn test_sync_trigger_error_responses() {
// Test various error scenarios and their expected HTTP responses
let test_cases = vec![
(SyncTriggerError::SourceNotFound, StatusCode::NOT_FOUND),
(SyncTriggerError::AlreadySyncing, StatusCode::CONFLICT),
(SyncTriggerError::SourceDisabled, StatusCode::BAD_REQUEST),
(SyncTriggerError::InvalidConfiguration, StatusCode::BAD_REQUEST),
(SyncTriggerError::PermissionDenied, StatusCode::FORBIDDEN),
(SyncTriggerError::RateLimited, StatusCode::TOO_MANY_REQUESTS),
(SyncTriggerError::InternalError, StatusCode::INTERNAL_SERVER_ERROR),
];
for (error, expected_status) in test_cases {
let status = error.to_status_code();
assert_eq!(status, expected_status, "Wrong status code for error: {:?}", error);
}
}
#[derive(Debug, Clone)]
enum SyncTriggerError {
SourceNotFound,
AlreadySyncing,
SourceDisabled,
InvalidConfiguration,
PermissionDenied,
RateLimited,
InternalError,
}
impl SyncTriggerError {
fn to_status_code(&self) -> StatusCode {
match self {
SyncTriggerError::SourceNotFound => StatusCode::NOT_FOUND,
SyncTriggerError::AlreadySyncing => StatusCode::CONFLICT,
SyncTriggerError::SourceDisabled => StatusCode::BAD_REQUEST,
SyncTriggerError::InvalidConfiguration => StatusCode::BAD_REQUEST,
SyncTriggerError::PermissionDenied => StatusCode::FORBIDDEN,
SyncTriggerError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
SyncTriggerError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
#[test]
fn test_manual_sync_metrics() {
// Test tracking of manual sync triggers vs automatic syncs
let mut sync_metrics = ManualSyncMetrics::new();
let source_id = Uuid::new_v4();
// Record manual triggers
sync_metrics.record_manual_trigger(source_id);
sync_metrics.record_manual_trigger(source_id);
// Record automatic syncs
sync_metrics.record_automatic_sync(source_id);
let stats = sync_metrics.get_stats_for_source(&source_id);
assert_eq!(stats.manual_triggers, 2);
assert_eq!(stats.automatic_syncs, 1);
assert_eq!(stats.total_syncs(), 3);
let manual_ratio = stats.manual_trigger_ratio();
assert!((manual_ratio - 0.666).abs() < 0.01); // ~66.7%
}
struct ManualSyncMetrics {
manual_triggers: HashMap<Uuid, u32>,
automatic_syncs: HashMap<Uuid, u32>,
}
impl ManualSyncMetrics {
fn new() -> Self {
Self {
manual_triggers: HashMap::new(),
automatic_syncs: HashMap::new(),
}
}
fn record_manual_trigger(&mut self, source_id: Uuid) {
*self.manual_triggers.entry(source_id).or_insert(0) += 1;
}
fn record_automatic_sync(&mut self, source_id: Uuid) {
*self.automatic_syncs.entry(source_id).or_insert(0) += 1;
}
fn get_stats_for_source(&self, source_id: &Uuid) -> SyncStats {
SyncStats {
manual_triggers: self.manual_triggers.get(source_id).copied().unwrap_or(0),
automatic_syncs: self.automatic_syncs.get(source_id).copied().unwrap_or(0),
}
}
}
struct SyncStats {
manual_triggers: u32,
automatic_syncs: u32,
}
impl SyncStats {
fn total_syncs(&self) -> u32 {
self.manual_triggers + self.automatic_syncs
}
fn manual_trigger_ratio(&self) -> f64 {
if self.total_syncs() == 0 {
0.0
} else {
self.manual_triggers as f64 / self.total_syncs() as f64
}
}
}
#[test]
fn test_sync_trigger_audit_logging() {
// Test audit logging for manual sync triggers
let mut audit_log = SyncAuditLog::new();
let user_id = Uuid::new_v4();
let source_id = Uuid::new_v4();
// Record successful trigger
audit_log.log_sync_trigger(SyncTriggerEvent {
user_id,
source_id,
timestamp: Utc::now(),
result: SyncTriggerResult::Success,
user_agent: Some("Mozilla/5.0 (Test Browser)".to_string()),
ip_address: Some("192.168.1.100".to_string()),
});
// Record failed trigger
audit_log.log_sync_trigger(SyncTriggerEvent {
user_id,
source_id,
timestamp: Utc::now(),
result: SyncTriggerResult::Failed("Already syncing".to_string()),
user_agent: Some("Mozilla/5.0 (Test Browser)".to_string()),
ip_address: Some("192.168.1.100".to_string()),
});
let events = audit_log.get_events_for_user(&user_id);
assert_eq!(events.len(), 2);
assert!(matches!(events[0].result, SyncTriggerResult::Success));
assert!(matches!(events[1].result, SyncTriggerResult::Failed(_)));
}
struct SyncAuditLog {
events: Vec<SyncTriggerEvent>,
}
impl SyncAuditLog {
fn new() -> Self {
Self {
events: Vec::new(),
}
}
fn log_sync_trigger(&mut self, event: SyncTriggerEvent) {
self.events.push(event);
}
fn get_events_for_user(&self, user_id: &Uuid) -> Vec<&SyncTriggerEvent> {
self.events.iter().filter(|e| e.user_id == *user_id).collect()
}
}
#[derive(Debug, Clone)]
struct SyncTriggerEvent {
user_id: Uuid,
source_id: Uuid,
timestamp: chrono::DateTime<Utc>,
result: SyncTriggerResult,
user_agent: Option<String>,
ip_address: Option<String>,
}
#[derive(Debug, Clone)]
enum SyncTriggerResult {
Success,
Failed(String),
}
#[tokio::test]
async fn test_sync_trigger_with_scheduler_integration() {
// Test integration with source scheduler
let state = create_test_app_state().await;
let user = create_test_user();
let source = create_test_source_with_status(SourceStatus::Idle, user.id);
// Test that trigger_sync method exists and handles the source
let sync_request = ManualSyncRequest {
source_id: source.id,
user_id: user.id,
force: false, // Don't force if already syncing
priority: SyncPriority::Normal,
};
// Simulate what the actual API would do
let can_proceed = validate_sync_request(&sync_request, &source);
assert!(can_proceed, "Valid sync request should be allowed");
}
#[derive(Debug, Clone)]
struct ManualSyncRequest {
source_id: Uuid,
user_id: Uuid,
force: bool,
priority: SyncPriority,
}
#[derive(Debug, Clone)]
enum SyncPriority {
Low,
Normal,
High,
Urgent,
}
fn validate_sync_request(request: &ManualSyncRequest, source: &Source) -> bool {
// Check ownership
if request.user_id != source.user_id {
return false;
}
// Check if source is enabled
if !source.enabled {
return false;
}
// Check status (allow force override)
if !request.force && source.status == SourceStatus::Syncing {
return false;
}
true
}
+598
View File
@@ -0,0 +1,598 @@
/*!
* S3 Sync Service Unit Tests
*
* Tests for S3 synchronization functionality including:
* - AWS S3 and MinIO compatibility
* - Credential handling and validation
* - Bucket operations and permissions
* - Object listing and metadata
* - Prefix-based filtering
* - Error handling and retry logic
* - Regional and endpoint configuration
*/
use std::collections::HashMap;
use uuid::Uuid;
use chrono::Utc;
use serde_json::json;
use readur::{
models::{S3SourceConfig, SourceType},
};
/// Create a test S3 configuration for AWS
fn create_test_aws_s3_config() -> S3SourceConfig {
S3SourceConfig {
bucket: "test-documents-bucket".to_string(),
region: "us-east-1".to_string(),
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
prefix: "documents/".to_string(),
endpoint_url: None, // Use AWS S3
auto_sync: true,
sync_interval_minutes: 120,
file_extensions: vec![".pdf".to_string(), ".txt".to_string(), ".docx".to_string()],
}
}
/// Create a test S3 configuration for MinIO
fn create_test_minio_config() -> S3SourceConfig {
S3SourceConfig {
bucket: "minio-test-bucket".to_string(),
region: "us-east-1".to_string(),
access_key_id: "minioadmin".to_string(),
secret_access_key: "minioadmin".to_string(),
prefix: "".to_string(),
endpoint_url: Some("https://minio.example.com".to_string()),
auto_sync: true,
sync_interval_minutes: 60,
file_extensions: vec![".pdf".to_string(), ".jpg".to_string()],
}
}
#[test]
fn test_s3_config_creation_aws() {
let config = create_test_aws_s3_config();
assert_eq!(config.bucket, "test-documents-bucket");
assert_eq!(config.region, "us-east-1");
assert!(!config.access_key_id.is_empty());
assert!(!config.secret_access_key.is_empty());
assert_eq!(config.prefix, "documents/");
assert!(config.endpoint_url.is_none()); // AWS S3
assert!(config.auto_sync);
assert_eq!(config.sync_interval_minutes, 120);
assert_eq!(config.file_extensions.len(), 3);
}
#[test]
fn test_s3_config_creation_minio() {
let config = create_test_minio_config();
assert_eq!(config.bucket, "minio-test-bucket");
assert_eq!(config.region, "us-east-1");
assert_eq!(config.access_key_id, "minioadmin");
assert_eq!(config.secret_access_key, "minioadmin");
assert_eq!(config.prefix, "");
assert!(config.endpoint_url.is_some());
assert_eq!(config.endpoint_url.unwrap(), "https://minio.example.com");
assert_eq!(config.sync_interval_minutes, 60);
}
#[test]
fn test_s3_config_validation() {
let config = create_test_aws_s3_config();
// Test bucket name validation
assert!(!config.bucket.is_empty());
assert!(config.bucket.len() >= 3 && config.bucket.len() <= 63);
assert!(!config.bucket.contains(' '));
assert!(!config.bucket.contains('_'));
assert!(config.bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
// Test region validation
assert!(!config.region.is_empty());
assert!(is_valid_aws_region(&config.region));
// Test credentials validation
assert!(!config.access_key_id.is_empty());
assert!(!config.secret_access_key.is_empty());
assert!(config.access_key_id.len() >= 16);
assert!(config.secret_access_key.len() >= 16);
// Test sync interval validation
assert!(config.sync_interval_minutes > 0);
// Test file extensions validation
assert!(!config.file_extensions.is_empty());
for ext in &config.file_extensions {
assert!(ext.starts_with('.'));
}
}
fn is_valid_aws_region(region: &str) -> bool {
let valid_regions = vec![
"us-east-1", "us-east-2", "us-west-1", "us-west-2",
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1",
"ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2",
"ap-south-1", "sa-east-1", "ca-central-1"
];
valid_regions.contains(&region)
}
#[test]
fn test_s3_endpoint_url_validation() {
let test_cases = vec![
("https://s3.amazonaws.com", true),
("https://minio.example.com", true),
("https://storage.googleapis.com", true),
("http://localhost:9000", true), // MinIO development
("ftp://invalid.com", false),
("not-a-url", false),
("", false),
];
for (endpoint, should_be_valid) in test_cases {
let is_valid = validate_endpoint_url(endpoint);
assert_eq!(is_valid, should_be_valid, "Endpoint validation failed for: {}", endpoint);
}
}
fn validate_endpoint_url(url: &str) -> bool {
if url.is_empty() {
return false;
}
url.starts_with("http://") || url.starts_with("https://")
}
#[test]
fn test_s3_object_key_handling() {
let prefix = "documents/";
let filename = "test file (1).pdf";
// Test object key construction
let object_key = construct_object_key(prefix, filename);
assert_eq!(object_key, "documents/test file (1).pdf");
// Test key normalization for S3
let normalized_key = normalize_s3_key(&object_key);
assert!(!normalized_key.contains("//"));
assert!(!normalized_key.starts_with('/'));
// Test key extraction from full path
let extracted_filename = extract_filename_from_key(&object_key);
assert_eq!(extracted_filename, filename);
}
fn construct_object_key(prefix: &str, filename: &str) -> String {
if prefix.is_empty() {
filename.to_string()
} else if prefix.ends_with('/') {
format!("{}{}", prefix, filename)
} else {
format!("{}/{}", prefix, filename)
}
}
fn normalize_s3_key(key: &str) -> String {
let mut normalized = key.to_string();
// Remove leading slash
if normalized.starts_with('/') {
normalized = normalized[1..].to_string();
}
// Remove double slashes
while normalized.contains("//") {
normalized = normalized.replace("//", "/");
}
normalized
}
fn extract_filename_from_key(key: &str) -> &str {
key.split('/').last().unwrap_or(key)
}
#[test]
fn test_s3_metadata_structure() {
use std::collections::HashMap;
let object_metadata = S3ObjectMetadata {
key: "documents/test.pdf".to_string(),
size: 1048576, // 1MB
last_modified: Utc::now(),
etag: "d41d8cd98f00b204e9800998ecf8427e".to_string(),
content_type: "application/pdf".to_string(),
metadata: {
let mut map = HashMap::new();
map.insert("original-filename".to_string(), "test.pdf".to_string());
map.insert("upload-user".to_string(), "testuser".to_string());
map
},
};
assert_eq!(object_metadata.key, "documents/test.pdf");
assert_eq!(object_metadata.size, 1048576);
assert!(!object_metadata.etag.is_empty());
assert_eq!(object_metadata.content_type, "application/pdf");
assert!(!object_metadata.metadata.is_empty());
// Test filename extraction
let filename = extract_filename_from_key(&object_metadata.key);
assert_eq!(filename, "test.pdf");
// Test size validation
assert!(object_metadata.size > 0);
// Test ETag format (MD5 hash)
assert_eq!(object_metadata.etag.len(), 32);
assert!(object_metadata.etag.chars().all(|c| c.is_ascii_hexdigit()));
}
#[derive(Debug, Clone)]
struct S3ObjectMetadata {
key: String,
size: u64,
last_modified: chrono::DateTime<Utc>,
etag: String,
content_type: String,
metadata: HashMap<String, String>,
}
#[test]
fn test_prefix_filtering() {
let config = create_test_aws_s3_config();
let prefix = &config.prefix;
let test_objects = vec![
"documents/file1.pdf",
"documents/subfolder/file2.txt",
"images/photo.jpg",
"temp/cache.tmp",
"documents/report.docx",
];
let filtered_objects: Vec<_> = test_objects.iter()
.filter(|obj| obj.starts_with(prefix))
.collect();
assert_eq!(filtered_objects.len(), 3); // Only documents/* objects
assert!(filtered_objects.contains(&&"documents/file1.pdf"));
assert!(filtered_objects.contains(&&"documents/subfolder/file2.txt"));
assert!(filtered_objects.contains(&&"documents/report.docx"));
assert!(!filtered_objects.contains(&&"images/photo.jpg"));
}
#[test]
fn test_file_extension_filtering_s3() {
let config = create_test_aws_s3_config();
let allowed_extensions = &config.file_extensions;
let test_objects = vec![
"documents/report.pdf",
"documents/notes.txt",
"documents/presentation.pptx",
"documents/spreadsheet.xlsx",
"documents/document.docx",
"documents/archive.zip",
"documents/image.jpg",
];
let filtered_objects: Vec<_> = test_objects.iter()
.filter(|obj| {
let filename = extract_filename_from_key(obj);
let extension = extract_extension(filename);
allowed_extensions.contains(&extension)
})
.collect();
assert!(filtered_objects.contains(&&"documents/report.pdf"));
assert!(filtered_objects.contains(&&"documents/notes.txt"));
assert!(filtered_objects.contains(&&"documents/document.docx"));
assert!(!filtered_objects.contains(&&"documents/presentation.pptx"));
assert!(!filtered_objects.contains(&&"documents/archive.zip"));
}
fn extract_extension(filename: &str) -> String {
if let Some(pos) = filename.rfind('.') {
filename[pos..].to_lowercase()
} else {
String::new()
}
}
#[test]
fn test_etag_change_detection_s3() {
let old_etag = "d41d8cd98f00b204e9800998ecf8427e";
let new_etag = "098f6bcd4621d373cade4e832627b4f6";
let same_etag = "d41d8cd98f00b204e9800998ecf8427e";
// Test change detection
assert_ne!(old_etag, new_etag, "Different ETags should indicate object change");
assert_eq!(old_etag, same_etag, "Same ETags should indicate no change");
// Test ETag normalization (S3 sometimes includes quotes)
let quoted_etag = "\"d41d8cd98f00b204e9800998ecf8427e\"";
let normalized_etag = quoted_etag.trim_matches('"');
assert_eq!(normalized_etag, old_etag);
// Test multipart upload ETag format (contains dash)
let multipart_etag = "d41d8cd98f00b204e9800998ecf8427e-2";
assert!(multipart_etag.contains('-'));
let base_etag = multipart_etag.split('-').next().unwrap();
assert_eq!(base_etag.len(), 32);
}
#[test]
fn test_aws_vs_minio_differences() {
let aws_config = create_test_aws_s3_config();
let minio_config = create_test_minio_config();
// AWS S3 uses standard endpoints
assert!(aws_config.endpoint_url.is_none());
// MinIO uses custom endpoints
assert!(minio_config.endpoint_url.is_some());
let minio_endpoint = minio_config.endpoint_url.unwrap();
assert!(minio_endpoint.starts_with("https://"));
// Both should support the same regions format
assert!(is_valid_aws_region(&aws_config.region));
assert!(is_valid_aws_region(&minio_config.region));
// MinIO often uses simpler credentials
assert_eq!(minio_config.access_key_id, "minioadmin");
assert_eq!(minio_config.secret_access_key, "minioadmin");
// AWS uses more complex credential formats
assert!(aws_config.access_key_id.len() >= 16);
assert!(aws_config.secret_access_key.len() >= 16);
}
#[test]
fn test_s3_error_handling_scenarios() {
// Test various error scenarios
// Invalid bucket name
let invalid_bucket_config = S3SourceConfig {
bucket: "Invalid_Bucket_Name!".to_string(), // Invalid characters
region: "us-east-1".to_string(),
access_key_id: "test".to_string(),
secret_access_key: "test".to_string(),
prefix: "".to_string(),
endpoint_url: None,
auto_sync: true,
sync_interval_minutes: 60,
file_extensions: vec![".pdf".to_string()],
};
assert!(invalid_bucket_config.bucket.contains('_'));
assert!(invalid_bucket_config.bucket.contains('!'));
// Empty credentials
let empty_creds_config = S3SourceConfig {
bucket: "test-bucket".to_string(),
region: "us-east-1".to_string(),
access_key_id: "".to_string(), // Empty
secret_access_key: "".to_string(), // Empty
prefix: "".to_string(),
endpoint_url: None,
auto_sync: true,
sync_interval_minutes: 60,
file_extensions: vec![".pdf".to_string()],
};
assert!(empty_creds_config.access_key_id.is_empty());
assert!(empty_creds_config.secret_access_key.is_empty());
// Invalid region
let invalid_region_config = S3SourceConfig {
bucket: "test-bucket".to_string(),
region: "invalid-region".to_string(),
access_key_id: "test".to_string(),
secret_access_key: "test".to_string(),
prefix: "".to_string(),
endpoint_url: None,
auto_sync: true,
sync_interval_minutes: 60,
file_extensions: vec![".pdf".to_string()],
};
assert!(!is_valid_aws_region(&invalid_region_config.region));
}
#[test]
fn test_s3_performance_considerations() {
// Test performance-related configurations
let performance_config = S3PerformanceConfig {
max_concurrent_requests: 10,
request_timeout_seconds: 30,
retry_attempts: 3,
retry_backoff_base_ms: 1000,
use_multipart_threshold_mb: 100,
multipart_chunk_size_mb: 5,
};
assert!(performance_config.max_concurrent_requests > 0);
assert!(performance_config.max_concurrent_requests <= 100); // Reasonable limit
assert!(performance_config.request_timeout_seconds >= 5);
assert!(performance_config.retry_attempts <= 5); // Don't retry too many times
assert!(performance_config.retry_backoff_base_ms >= 100);
assert!(performance_config.use_multipart_threshold_mb >= 5); // AWS minimum is 5MB
assert!(performance_config.multipart_chunk_size_mb >= 5); // AWS minimum is 5MB
}
#[derive(Debug, Clone)]
struct S3PerformanceConfig {
max_concurrent_requests: u32,
request_timeout_seconds: u32,
retry_attempts: u32,
retry_backoff_base_ms: u64,
use_multipart_threshold_mb: u64,
multipart_chunk_size_mb: u64,
}
#[test]
fn test_s3_retry_logic() {
// Test exponential backoff for retry logic
fn calculate_s3_retry_delay(attempt: u32, base_delay_ms: u64) -> u64 {
let max_delay_ms = 60_000; // 60 seconds max for S3
let jitter_factor = 0.1; // 10% jitter
let delay = base_delay_ms * 2_u64.pow(attempt.saturating_sub(1));
let with_jitter = (delay as f64 * (1.0 + jitter_factor)) as u64;
std::cmp::min(with_jitter, max_delay_ms)
}
assert_eq!(calculate_s3_retry_delay(1, 1000), 1100); // ~1.1 seconds
assert_eq!(calculate_s3_retry_delay(2, 1000), 2200); // ~2.2 seconds
assert_eq!(calculate_s3_retry_delay(3, 1000), 4400); // ~4.4 seconds
assert!(calculate_s3_retry_delay(10, 1000) <= 60000); // Capped at 60 seconds
}
#[test]
fn test_s3_url_presigning() {
// Test URL presigning concepts (for download URLs)
let object_key = "documents/test.pdf";
let bucket = "test-bucket";
let region = "us-east-1";
let expiry_seconds = 3600; // 1 hour
// Construct what a presigned URL would look like
let base_url = format!("https://{}.s3.{}.amazonaws.com/{}", bucket, region, object_key);
let presigned_url = format!("{}?X-Amz-Expires={}&X-Amz-Signature=...", base_url, expiry_seconds);
assert!(presigned_url.contains("amazonaws.com"));
assert!(presigned_url.contains("X-Amz-Expires"));
assert!(presigned_url.contains("X-Amz-Signature"));
assert!(presigned_url.contains(&expiry_seconds.to_string()));
// Test expiry validation
assert!(expiry_seconds > 0);
assert!(expiry_seconds <= 7 * 24 * 3600); // Max 7 days for AWS
}
#[test]
fn test_s3_batch_operations() {
// Test batch operations for better performance
let object_keys = vec![
"documents/file1.pdf",
"documents/file2.txt",
"documents/file3.docx",
"documents/file4.pdf",
"documents/file5.txt",
];
// Test batching logic
let batch_size = 2;
let batches: Vec<Vec<&str>> = object_keys.chunks(batch_size).map(|chunk| chunk.to_vec()).collect();
assert_eq!(batches.len(), 3); // 5 items / 2 = 3 batches
assert_eq!(batches[0].len(), 2);
assert_eq!(batches[1].len(), 2);
assert_eq!(batches[2].len(), 1); // Last batch has remainder
// Test total items preservation
let total_items: usize = batches.iter().map(|b| b.len()).sum();
assert_eq!(total_items, object_keys.len());
}
#[test]
fn test_s3_content_type_detection() {
let test_files = vec![
("document.pdf", "application/pdf"),
("image.jpg", "image/jpeg"),
("image.png", "image/png"),
("text.txt", "text/plain"),
("data.json", "application/json"),
("archive.zip", "application/zip"),
("unknown.xyz", "application/octet-stream"), // Default
];
for (filename, expected_content_type) in test_files {
let detected_type = detect_content_type(filename);
assert_eq!(detected_type, expected_content_type,
"Content type detection failed for: {}", filename);
}
}
fn detect_content_type(filename: &str) -> &'static str {
match filename.split('.').last().unwrap_or("").to_lowercase().as_str() {
"pdf" => "application/pdf",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"txt" => "text/plain",
"json" => "application/json",
"zip" => "application/zip",
_ => "application/octet-stream",
}
}
#[test]
fn test_s3_storage_classes() {
// Test different S3 storage classes for cost optimization
let storage_classes = vec![
"STANDARD",
"STANDARD_IA",
"ONEZONE_IA",
"GLACIER",
"DEEP_ARCHIVE",
"INTELLIGENT_TIERING",
];
for storage_class in storage_classes {
assert!(!storage_class.is_empty());
assert!(storage_class.chars().all(|c| c.is_ascii_uppercase() || c == '_'));
}
// Test default storage class
let default_storage_class = "STANDARD";
assert_eq!(default_storage_class, "STANDARD");
}
#[test]
fn test_concurrent_download_safety() {
use std::sync::{Arc, Mutex};
use std::thread;
let download_stats = Arc::new(Mutex::new(DownloadStats {
files_downloaded: 0,
bytes_downloaded: 0,
errors: 0,
}));
let mut handles = vec![];
// Simulate concurrent downloads
for i in 0..5 {
let stats = Arc::clone(&download_stats);
let handle = thread::spawn(move || {
// Simulate download
let file_size = 1024 * (i + 1); // Variable file sizes
let mut stats = stats.lock().unwrap();
stats.files_downloaded += 1;
stats.bytes_downloaded += file_size;
});
handles.push(handle);
}
// Wait for all downloads to complete
for handle in handles {
handle.join().unwrap();
}
let final_stats = download_stats.lock().unwrap();
assert_eq!(final_stats.files_downloaded, 5);
assert!(final_stats.bytes_downloaded > 0);
assert_eq!(final_stats.errors, 0);
}
#[derive(Debug, Clone)]
struct DownloadStats {
files_downloaded: u32,
bytes_downloaded: u64,
errors: u32,
}
+306
View File
@@ -0,0 +1,306 @@
/*!
* Simple Source Scheduler Unit Tests
*
* Basic tests for the source scheduler functionality without complex mocking
*/
use std::sync::Arc;
use uuid::Uuid;
use chrono::Utc;
use serde_json::json;
use readur::{
AppState,
config::Config,
db::Database,
models::{Source, SourceType, SourceStatus, WebDAVSourceConfig, LocalFolderSourceConfig, S3SourceConfig},
source_scheduler::SourceScheduler,
};
/// Create a test app state
async fn create_test_app_state() -> Arc<AppState> {
let config = Config {
database_url: "sqlite::memory:".to_string(),
server_address: "127.0.0.1:8080".to_string(),
jwt_secret: "test_secret".to_string(),
upload_path: "/tmp/test_uploads".to_string(),
watch_folder: "/tmp/watch".to_string(),
allowed_file_types: vec!["pdf".to_string(), "txt".to_string()],
watch_interval_seconds: Some(10),
file_stability_check_ms: Some(1000),
max_file_age_hours: Some(24),
ocr_language: "eng".to_string(),
concurrent_ocr_jobs: 4,
ocr_timeout_seconds: 300,
max_file_size_mb: 100,
memory_limit_mb: 512,
cpu_priority: "normal".to_string(),
};
let db = Database::new(&config.database_url).await.unwrap();
Arc::new(AppState {
db,
config,
webdav_scheduler: None,
source_scheduler: None,
})
}
#[tokio::test]
async fn test_source_scheduler_creation() {
let state = create_test_app_state().await;
let _scheduler = SourceScheduler::new(state.clone());
// Test that scheduler is created successfully
assert!(true); // If we get here, creation succeeded
}
#[test]
fn test_webdav_config_parsing() {
let config_json = json!({
"server_url": "https://cloud.example.com",
"username": "testuser",
"password": "testpass",
"watch_folders": ["/Documents"],
"file_extensions": [".pdf", ".txt"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
});
let config: Result<WebDAVSourceConfig, _> = serde_json::from_value(config_json);
assert!(config.is_ok(), "WebDAV config should parse successfully");
let webdav_config = config.unwrap();
assert_eq!(webdav_config.server_url, "https://cloud.example.com");
assert_eq!(webdav_config.username, "testuser");
assert!(webdav_config.auto_sync);
assert_eq!(webdav_config.sync_interval_minutes, 60);
assert_eq!(webdav_config.server_type, Some("nextcloud".to_string()));
}
#[test]
fn test_local_folder_config_parsing() {
let config_json = json!({
"watch_folders": ["/home/user/documents"],
"file_extensions": [".pdf", ".txt", ".jpg"],
"auto_sync": true,
"sync_interval_minutes": 30,
"recursive": true,
"follow_symlinks": false
});
let config: Result<LocalFolderSourceConfig, _> = serde_json::from_value(config_json);
assert!(config.is_ok(), "Local Folder config should parse successfully");
let local_config = config.unwrap();
assert_eq!(local_config.watch_folders.len(), 1);
assert_eq!(local_config.watch_folders[0], "/home/user/documents");
assert!(local_config.recursive);
assert!(!local_config.follow_symlinks);
assert_eq!(local_config.sync_interval_minutes, 30);
}
#[test]
fn test_s3_config_parsing() {
let config_json = json!({
"bucket_name": "test-documents",
"region": "us-east-1",
"access_key_id": "AKIATEST",
"secret_access_key": "secrettest",
"endpoint_url": null,
"prefix": "documents/",
"watch_folders": ["documents/"],
"file_extensions": [".pdf", ".docx"],
"auto_sync": true,
"sync_interval_minutes": 120
});
let config: Result<S3SourceConfig, _> = serde_json::from_value(config_json);
assert!(config.is_ok(), "S3 config should parse successfully");
let s3_config = config.unwrap();
assert_eq!(s3_config.bucket_name, "test-documents");
assert_eq!(s3_config.region, "us-east-1");
assert_eq!(s3_config.prefix, Some("documents/".to_string()));
assert_eq!(s3_config.sync_interval_minutes, 120);
}
#[test]
fn test_source_type_enum() {
assert_eq!(SourceType::WebDAV.to_string(), "webdav");
assert_eq!(SourceType::LocalFolder.to_string(), "local_folder");
assert_eq!(SourceType::S3.to_string(), "s3");
}
#[test]
fn test_source_status_enum() {
assert_eq!(SourceStatus::Idle.to_string(), "idle");
assert_eq!(SourceStatus::Syncing.to_string(), "syncing");
assert_eq!(SourceStatus::Error.to_string(), "error");
}
#[test]
fn test_interrupted_sync_detection() {
let user_id = Uuid::new_v4();
let interrupted_source = Source {
id: Uuid::new_v4(),
user_id,
name: "Test Source".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://cloud.example.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
}),
status: SourceStatus::Syncing, // This indicates interruption
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Test that interrupted sync is detected
assert_eq!(interrupted_source.status, SourceStatus::Syncing);
let completed_source = Source {
id: Uuid::new_v4(),
user_id,
name: "Completed Source".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://cloud.example.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
}),
status: SourceStatus::Idle, // Completed normally
last_sync_at: Some(Utc::now()),
last_error: None,
last_error_at: None,
total_files_synced: 10,
total_files_pending: 0,
total_size_bytes: 1024,
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert_eq!(completed_source.status, SourceStatus::Idle);
}
#[test]
fn test_auto_sync_configuration() {
// Test WebDAV auto sync enabled
let webdav_config = WebDAVSourceConfig {
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()],
auto_sync: true,
sync_interval_minutes: 60,
server_type: Some("nextcloud".to_string()),
};
assert!(webdav_config.auto_sync);
assert_eq!(webdav_config.sync_interval_minutes, 60);
// Test auto sync disabled
let webdav_disabled = WebDAVSourceConfig {
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()],
auto_sync: false,
sync_interval_minutes: 60,
server_type: Some("nextcloud".to_string()),
};
assert!(!webdav_disabled.auto_sync);
}
#[test]
fn test_sync_interval_validation() {
let valid_intervals = vec![1, 15, 30, 60, 120, 240];
let invalid_intervals = vec![0, -1, -30];
for interval in valid_intervals {
assert!(interval > 0, "Valid interval should be positive: {}", interval);
}
for interval in invalid_intervals {
assert!(interval <= 0, "Invalid interval should be non-positive: {}", interval);
}
}
#[test]
fn test_file_extension_validation() {
let valid_extensions = vec![".pdf", ".txt", ".jpg", ".png", ".docx"];
let invalid_extensions = vec!["pdf", "txt", "", "no-dot"];
for ext in valid_extensions {
assert!(ext.starts_with('.'), "Valid extension should start with dot: {}", ext);
assert!(!ext.is_empty(), "Valid extension should not be empty");
}
for ext in invalid_extensions {
if !ext.is_empty() {
assert!(!ext.starts_with('.') || ext.len() == 1, "Invalid extension: {}", ext);
}
}
}
#[tokio::test]
async fn test_trigger_sync_basic() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
// Test triggering sync with non-existent source
let non_existent_id = Uuid::new_v4();
let result = scheduler.trigger_sync(non_existent_id).await;
// Should return error for non-existent source
assert!(result.is_err());
}
#[test]
fn test_source_configuration_sizes() {
// Test that configurations don't grow too large
let webdav_config = WebDAVSourceConfig {
server_url: "https://very-long-server-url-that-might-be-too-long.example.com".to_string(),
username: "test".to_string(),
password: "test".to_string(),
watch_folders: vec!["/folder1".to_string(), "/folder2".to_string()],
file_extensions: vec![".pdf".to_string(), ".txt".to_string(), ".jpg".to_string()],
auto_sync: true,
sync_interval_minutes: 60,
server_type: Some("nextcloud".to_string()),
};
let serialized = serde_json::to_string(&webdav_config).unwrap();
assert!(serialized.len() < 1024, "Config should not be too large");
// Test that required fields are present
assert!(!webdav_config.server_url.is_empty());
assert!(!webdav_config.username.is_empty());
assert!(!webdav_config.watch_folders.is_empty());
assert!(!webdav_config.file_extensions.is_empty());
}
+638
View File
@@ -0,0 +1,638 @@
/*!
* Source Scheduler Unit Tests
*
* Tests for the universal source scheduler functionality including:
* - Auto-resume sync after server restart
* - Background sync scheduling
* - Manual sync triggering
* - Source type detection and routing
* - Error handling and recovery
*/
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use chrono::Utc;
use serde_json::json;
use readur::{
AppState,
config::Config,
db::Database,
models::{Source, SourceType, SourceStatus, WebDAVSourceConfig, LocalFolderSourceConfig, S3SourceConfig},
source_scheduler::SourceScheduler,
};
/// Mock database for testing
struct MockDatabase {
sources: Vec<Source>,
sources_for_sync: Vec<Source>,
}
impl MockDatabase {
fn new() -> Self {
Self {
sources: Vec::new(),
sources_for_sync: Vec::new(),
}
}
fn with_interrupted_source(mut self, name: &str, source_type: SourceType) -> Self {
let mut config = json!({});
match source_type {
SourceType::WebDAV => {
config = json!({
"server_url": "https://test.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf", ".txt"],
"auto_sync": true,
"sync_interval_minutes": 60
});
},
SourceType::LocalFolder => {
config = json!({
"paths": ["/test/folder"],
"recursive": true,
"follow_symlinks": false,
"auto_sync": true,
"sync_interval_minutes": 30
});
},
SourceType::S3 => {
config = json!({
"bucket": "test-bucket",
"region": "us-east-1",
"access_key_id": "test",
"secret_access_key": "test",
"prefix": "",
"auto_sync": true,
"sync_interval_minutes": 120
});
}
}
self.sources.push(Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: name.to_string(),
source_type,
enabled: true,
config,
status: SourceStatus::Syncing, // Interrupted state
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
});
self.sources_for_sync = self.sources.clone();
self
}
fn with_due_sync_source(mut self, name: &str, source_type: SourceType, minutes_ago: i64) -> Self {
let mut config = json!({});
match source_type {
SourceType::WebDAV => {
config = json!({
"server_url": "https://test.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf", ".txt"],
"auto_sync": true,
"sync_interval_minutes": 30
});
},
SourceType::LocalFolder => {
config = json!({
"paths": ["/test/folder"],
"recursive": true,
"follow_symlinks": false,
"auto_sync": true,
"sync_interval_minutes": 30
});
},
SourceType::S3 => {
config = json!({
"bucket": "test-bucket",
"region": "us-east-1",
"access_key_id": "test",
"secret_access_key": "test",
"prefix": "",
"auto_sync": true,
"sync_interval_minutes": 30
});
}
}
self.sources.push(Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: name.to_string(),
source_type,
enabled: true,
config,
status: SourceStatus::Idle,
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(minutes_ago)),
last_error: None,
last_error_at: None,
total_files_synced: 5,
total_files_pending: 0,
total_size_bytes: 1024,
created_at: Utc::now(),
updated_at: Utc::now(),
});
self.sources_for_sync = self.sources.clone();
self
}
}
async fn create_test_app_state() -> Arc<AppState> {
let config = Config {
database_url: "sqlite::memory:".to_string(),
server_address: "127.0.0.1:8080".to_string(),
jwt_secret: "test_secret".to_string(),
upload_dir: "/tmp/test_uploads".to_string(),
max_file_size: 10 * 1024 * 1024,
};
let db = Database::new(&config.database_url).await.unwrap();
Arc::new(AppState {
db,
config,
webdav_scheduler: None,
source_scheduler: None,
})
}
#[tokio::test]
async fn test_source_scheduler_creation() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
// Test that scheduler is created successfully
assert_eq!(scheduler.check_interval, Duration::from_secs(60));
}
#[tokio::test]
async fn test_interrupted_sync_detection_webdav() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
// Create a mock source that was interrupted during sync
let source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Test WebDAV".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://test.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf", ".txt"],
"auto_sync": true,
"sync_interval_minutes": 60
}),
status: SourceStatus::Syncing, // This indicates interruption
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Test that interrupted sync is detected
assert_eq!(source.status, SourceStatus::Syncing);
// Test config parsing for WebDAV
let config: Result<WebDAVSourceConfig, _> = serde_json::from_value(source.config.clone());
assert!(config.is_ok());
let webdav_config = config.unwrap();
assert!(webdav_config.auto_sync);
assert_eq!(webdav_config.sync_interval_minutes, 60);
}
#[tokio::test]
async fn test_interrupted_sync_detection_local_folder() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
let source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Test Local Folder".to_string(),
source_type: SourceType::LocalFolder,
enabled: true,
config: json!({
"paths": ["/test/folder"],
"recursive": true,
"follow_symlinks": false,
"auto_sync": true,
"sync_interval_minutes": 30
}),
status: SourceStatus::Syncing,
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Test config parsing for Local Folder
let config: Result<LocalFolderSourceConfig, _> = serde_json::from_value(source.config.clone());
assert!(config.is_ok());
let local_config = config.unwrap();
assert!(local_config.auto_sync);
assert_eq!(local_config.sync_interval_minutes, 30);
}
#[tokio::test]
async fn test_interrupted_sync_detection_s3() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
let source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Test S3".to_string(),
source_type: SourceType::S3,
enabled: true,
config: json!({
"bucket": "test-bucket",
"region": "us-east-1",
"access_key_id": "test",
"secret_access_key": "test",
"prefix": "",
"auto_sync": true,
"sync_interval_minutes": 120
}),
status: SourceStatus::Syncing,
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Test config parsing for S3
let config: Result<S3SourceConfig, _> = serde_json::from_value(source.config.clone());
assert!(config.is_ok());
let s3_config = config.unwrap();
assert!(s3_config.auto_sync);
assert_eq!(s3_config.sync_interval_minutes, 120);
}
#[tokio::test]
async fn test_sync_due_calculation() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
// Test source that should be due for sync
let old_sync_source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Old Sync".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://test.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf"],
"auto_sync": true,
"sync_interval_minutes": 30
}),
status: SourceStatus::Idle,
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(45)), // 45 minutes ago
last_error: None,
last_error_at: None,
total_files_synced: 5,
total_files_pending: 0,
total_size_bytes: 1024,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Test source that should NOT be due for sync
let recent_sync_source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Recent Sync".to_string(),
source_type: SourceType::LocalFolder,
enabled: true,
config: json!({
"paths": ["/test"],
"recursive": true,
"follow_symlinks": false,
"auto_sync": true,
"sync_interval_minutes": 60
}),
status: SourceStatus::Idle,
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(15)), // 15 minutes ago
last_error: None,
last_error_at: None,
total_files_synced: 10,
total_files_pending: 0,
total_size_bytes: 2048,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Test that sync due calculation works correctly
let old_result = scheduler.is_sync_due(&old_sync_source).await;
assert!(old_result.is_ok());
assert!(old_result.unwrap(), "Old sync should be due");
let recent_result = scheduler.is_sync_due(&recent_sync_source).await;
assert!(recent_result.is_ok());
assert!(!recent_result.unwrap(), "Recent sync should not be due");
}
#[tokio::test]
async fn test_auto_sync_disabled() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
let source_with_auto_sync_disabled = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Auto Sync Disabled".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://test.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf"],
"auto_sync": false, // Disabled
"sync_interval_minutes": 30
}),
status: SourceStatus::Idle,
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(45)),
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let result = scheduler.is_sync_due(&source_with_auto_sync_disabled).await;
assert!(result.is_ok());
assert!(!result.unwrap(), "Source with auto_sync disabled should not be due");
}
#[tokio::test]
async fn test_currently_syncing_source() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
let syncing_source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Currently Syncing".to_string(),
source_type: SourceType::S3,
enabled: true,
config: json!({
"bucket": "test-bucket",
"region": "us-east-1",
"access_key_id": "test",
"secret_access_key": "test",
"prefix": "",
"auto_sync": true,
"sync_interval_minutes": 30
}),
status: SourceStatus::Syncing, // Currently syncing
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(45)),
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 5,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let result = scheduler.is_sync_due(&syncing_source).await;
assert!(result.is_ok());
assert!(!result.unwrap(), "Currently syncing source should not be due for another sync");
}
#[tokio::test]
async fn test_invalid_sync_interval() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
let invalid_interval_source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Invalid Interval".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://test.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf"],
"auto_sync": true,
"sync_interval_minutes": 0 // Invalid interval
}),
status: SourceStatus::Idle,
last_sync_at: Some(Utc::now() - chrono::Duration::minutes(45)),
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let result = scheduler.is_sync_due(&invalid_interval_source).await;
assert!(result.is_ok());
assert!(!result.unwrap(), "Source with invalid sync interval should not be due");
}
#[tokio::test]
async fn test_never_synced_source() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
let never_synced_source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Never Synced".to_string(),
source_type: SourceType::LocalFolder,
enabled: true,
config: json!({
"paths": ["/test"],
"recursive": true,
"follow_symlinks": false,
"auto_sync": true,
"sync_interval_minutes": 60
}),
status: SourceStatus::Idle,
last_sync_at: None, // Never synced
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let result = scheduler.is_sync_due(&never_synced_source).await;
assert!(result.is_ok());
assert!(result.unwrap(), "Never synced source should be due for sync");
}
#[tokio::test]
async fn test_trigger_sync_nonexistent_source() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
let nonexistent_id = Uuid::new_v4();
let result = scheduler.trigger_sync(nonexistent_id).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "Source not found");
}
#[tokio::test]
async fn test_source_status_enum() {
// Test SourceStatus enum string conversion
assert_eq!(SourceStatus::Idle.to_string(), "idle");
assert_eq!(SourceStatus::Syncing.to_string(), "syncing");
assert_eq!(SourceStatus::Error.to_string(), "error");
}
#[tokio::test]
async fn test_source_type_enum() {
// Test SourceType enum
let webdav = SourceType::WebDAV;
let local = SourceType::LocalFolder;
let s3 = SourceType::S3;
assert_eq!(webdav.to_string(), "webdav");
assert_eq!(local.to_string(), "local_folder");
assert_eq!(s3.to_string(), "s3");
}
#[tokio::test]
async fn test_config_validation() {
// Test WebDAV config validation
let webdav_config = WebDAVSourceConfig {
server_url: "https://test.com".to_string(),
username: "user".to_string(),
password: "pass".to_string(),
watch_folders: vec!["/folder1".to_string(), "/folder2".to_string()],
file_extensions: vec![".pdf".to_string(), ".txt".to_string()],
auto_sync: true,
sync_interval_minutes: 60,
server_type: "nextcloud".to_string(),
};
assert!(!webdav_config.server_url.is_empty());
assert!(!webdav_config.username.is_empty());
assert!(!webdav_config.password.is_empty());
assert!(!webdav_config.watch_folders.is_empty());
assert!(webdav_config.sync_interval_minutes > 0);
// Test Local Folder config validation
let local_config = LocalFolderSourceConfig {
paths: vec!["/test/path".to_string()],
recursive: true,
follow_symlinks: false,
auto_sync: true,
sync_interval_minutes: 30,
file_extensions: vec![".pdf".to_string()],
};
assert!(!local_config.paths.is_empty());
assert!(local_config.sync_interval_minutes > 0);
// Test S3 config validation
let s3_config = S3SourceConfig {
bucket: "test-bucket".to_string(),
region: "us-east-1".to_string(),
access_key_id: "key".to_string(),
secret_access_key: "secret".to_string(),
prefix: "docs/".to_string(),
endpoint_url: Some("https://minio.example.com".to_string()),
auto_sync: true,
sync_interval_minutes: 120,
file_extensions: vec![".pdf".to_string()],
};
assert!(!s3_config.bucket.is_empty());
assert!(!s3_config.region.is_empty());
assert!(!s3_config.access_key_id.is_empty());
assert!(!s3_config.secret_access_key.is_empty());
assert!(s3_config.sync_interval_minutes > 0);
}
#[tokio::test]
async fn test_scheduler_timeout_handling() {
let state = create_test_app_state().await;
let scheduler = SourceScheduler::new(state.clone());
// Test that operations complete within reasonable time
let start = std::time::Instant::now();
let dummy_source = Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Timeout Test".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://test.com",
"username": "test",
"password": "test",
"watch_folders": ["/test"],
"file_extensions": [".pdf"],
"auto_sync": true,
"sync_interval_minutes": 60
}),
status: SourceStatus::Idle,
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let result = timeout(Duration::from_secs(1), scheduler.is_sync_due(&dummy_source)).await;
assert!(result.is_ok(), "Sync due calculation should complete quickly");
let elapsed = start.elapsed();
assert!(elapsed < Duration::from_millis(500), "Operation should be fast");
}
+743
View File
@@ -0,0 +1,743 @@
/*!
* Thread Separation and Performance Unit Tests
*
* Tests for thread separation and performance optimization including:
* - Dedicated runtime separation (OCR, Background, DB)
* - Thread pool isolation and resource allocation
* - Performance monitoring and metrics
* - Memory usage and CPU utilization
* - Contention prevention between sync and OCR
* - Database connection pool separation
*/
use std::sync::{Arc, Mutex, atomic::{AtomicU64, AtomicU32, Ordering}};
use std::time::{Duration, Instant, SystemTime};
use std::thread;
use uuid::Uuid;
use tokio::runtime::{Builder, Runtime};
use tokio::time::{sleep, timeout};
/// Test runtime configuration and separation
#[test]
fn test_runtime_configuration() {
// Test OCR runtime configuration
let ocr_runtime = Builder::new_multi_thread()
.worker_threads(3)
.thread_name("readur-ocr")
.enable_all()
.build();
assert!(ocr_runtime.is_ok(), "OCR runtime should be created successfully");
// Test background runtime configuration
let background_runtime = Builder::new_multi_thread()
.worker_threads(2)
.thread_name("readur-background")
.enable_all()
.build();
assert!(background_runtime.is_ok(), "Background runtime should be created successfully");
// Test DB runtime configuration
let db_runtime = Builder::new_multi_thread()
.worker_threads(2)
.thread_name("readur-db")
.enable_all()
.build();
assert!(db_runtime.is_ok(), "DB runtime should be created successfully");
}
#[test]
fn test_thread_pool_isolation() {
// Test that different thread pools don't interfere with each other
let ocr_counter = Arc::new(AtomicU32::new(0));
let background_counter = Arc::new(AtomicU32::new(0));
let db_counter = Arc::new(AtomicU32::new(0));
// Create separate runtimes
let ocr_rt = Builder::new_multi_thread()
.worker_threads(3)
.thread_name("test-ocr")
.build()
.unwrap();
let bg_rt = Builder::new_multi_thread()
.worker_threads(2)
.thread_name("test-bg")
.build()
.unwrap();
let db_rt = Builder::new_multi_thread()
.worker_threads(2)
.thread_name("test-db")
.build()
.unwrap();
// Run concurrent work on each runtime
let ocr_counter_clone = Arc::clone(&ocr_counter);
let ocr_handle = ocr_rt.spawn(async move {
for _ in 0..100 {
ocr_counter_clone.fetch_add(1, Ordering::Relaxed);
sleep(Duration::from_millis(1)).await;
}
});
let bg_counter_clone = Arc::clone(&background_counter);
let bg_handle = bg_rt.spawn(async move {
for _ in 0..100 {
bg_counter_clone.fetch_add(1, Ordering::Relaxed);
sleep(Duration::from_millis(1)).await;
}
});
let db_counter_clone = Arc::clone(&db_counter);
let db_handle = db_rt.spawn(async move {
for _ in 0..100 {
db_counter_clone.fetch_add(1, Ordering::Relaxed);
sleep(Duration::from_millis(1)).await;
}
});
// Wait for all work to complete
ocr_rt.block_on(ocr_handle).unwrap();
bg_rt.block_on(bg_handle).unwrap();
db_rt.block_on(db_handle).unwrap();
// Verify all work completed
assert_eq!(ocr_counter.load(Ordering::Relaxed), 100);
assert_eq!(background_counter.load(Ordering::Relaxed), 100);
assert_eq!(db_counter.load(Ordering::Relaxed), 100);
}
#[tokio::test]
async fn test_database_connection_pool_separation() {
// Test separate connection pools for different workloads
let web_pool = DatabaseConnectionPool::new("web", 20, 2);
let background_pool = DatabaseConnectionPool::new("background", 30, 3);
assert_eq!(web_pool.max_connections, 20);
assert_eq!(web_pool.min_connections, 2);
assert_eq!(web_pool.pool_name, "web");
assert_eq!(background_pool.max_connections, 30);
assert_eq!(background_pool.min_connections, 3);
assert_eq!(background_pool.pool_name, "background");
// Test connection acquisition
let web_conn = web_pool.acquire_connection().await;
assert!(web_conn.is_ok(), "Should acquire web connection");
let bg_conn = background_pool.acquire_connection().await;
assert!(bg_conn.is_ok(), "Should acquire background connection");
// Test that pools are isolated
assert_ne!(web_pool.pool_id, background_pool.pool_id);
}
#[derive(Debug, Clone)]
struct DatabaseConnectionPool {
pool_name: String,
max_connections: u32,
min_connections: u32,
pool_id: Uuid,
active_connections: Arc<AtomicU32>,
}
impl DatabaseConnectionPool {
fn new(name: &str, max_conn: u32, min_conn: u32) -> Self {
Self {
pool_name: name.to_string(),
max_connections: max_conn,
min_connections: min_conn,
pool_id: Uuid::new_v4(),
active_connections: Arc::new(AtomicU32::new(0)),
}
}
async fn acquire_connection(&self) -> Result<DatabaseConnection, String> {
let current = self.active_connections.load(Ordering::Relaxed);
if current >= self.max_connections {
return Err("Max connections reached".to_string());
}
self.active_connections.fetch_add(1, Ordering::Relaxed);
Ok(DatabaseConnection {
id: Uuid::new_v4(),
pool_name: self.pool_name.clone(),
})
}
}
#[derive(Debug)]
struct DatabaseConnection {
id: Uuid,
pool_name: String,
}
#[test]
fn test_performance_metrics_collection() {
let metrics = PerformanceMetrics::new();
// Test OCR metrics
metrics.record_ocr_operation(Duration::from_millis(150), true);
metrics.record_ocr_operation(Duration::from_millis(200), true);
metrics.record_ocr_operation(Duration::from_millis(100), false); // Failed
let ocr_stats = metrics.get_ocr_stats();
assert_eq!(ocr_stats.total_operations, 3);
assert_eq!(ocr_stats.successful_operations, 2);
assert_eq!(ocr_stats.failed_operations, 1);
let avg_duration = ocr_stats.average_duration();
assert!(avg_duration > Duration::from_millis(100));
assert!(avg_duration < Duration::from_millis(200));
// Test sync metrics
metrics.record_sync_operation(Duration::from_secs(30), 50, 45);
metrics.record_sync_operation(Duration::from_secs(45), 100, 95);
let sync_stats = metrics.get_sync_stats();
assert_eq!(sync_stats.total_operations, 2);
assert_eq!(sync_stats.total_files_processed, 150);
assert_eq!(sync_stats.total_files_successful, 140);
}
struct PerformanceMetrics {
ocr_operations: Arc<Mutex<Vec<OcrOperation>>>,
sync_operations: Arc<Mutex<Vec<SyncOperation>>>,
memory_samples: Arc<Mutex<Vec<MemorySample>>>,
}
impl PerformanceMetrics {
fn new() -> Self {
Self {
ocr_operations: Arc::new(Mutex::new(Vec::new())),
sync_operations: Arc::new(Mutex::new(Vec::new())),
memory_samples: Arc::new(Mutex::new(Vec::new())),
}
}
fn record_ocr_operation(&self, duration: Duration, success: bool) {
let mut ops = self.ocr_operations.lock().unwrap();
ops.push(OcrOperation {
duration,
success,
timestamp: SystemTime::now(),
});
}
fn record_sync_operation(&self, duration: Duration, files_found: u32, files_processed: u32) {
let mut ops = self.sync_operations.lock().unwrap();
ops.push(SyncOperation {
duration,
files_found,
files_processed,
timestamp: SystemTime::now(),
});
}
fn get_ocr_stats(&self) -> OcrStats {
let ops = self.ocr_operations.lock().unwrap();
let total = ops.len() as u32;
let successful = ops.iter().filter(|op| op.success).count() as u32;
let failed = total - successful;
let total_duration: Duration = ops.iter().map(|op| op.duration).sum();
OcrStats {
total_operations: total,
successful_operations: successful,
failed_operations: failed,
total_duration,
}
}
fn get_sync_stats(&self) -> SyncStats {
let ops = self.sync_operations.lock().unwrap();
let total = ops.len() as u32;
let total_files = ops.iter().map(|op| op.files_found).sum();
let successful_files = ops.iter().map(|op| op.files_processed).sum();
SyncStats {
total_operations: total,
total_files_processed: total_files,
total_files_successful: successful_files,
}
}
}
#[derive(Debug, Clone)]
struct OcrOperation {
duration: Duration,
success: bool,
timestamp: SystemTime,
}
#[derive(Debug, Clone)]
struct SyncOperation {
duration: Duration,
files_found: u32,
files_processed: u32,
timestamp: SystemTime,
}
#[derive(Debug, Clone)]
struct MemorySample {
heap_usage_mb: f64,
stack_usage_mb: f64,
timestamp: SystemTime,
}
#[derive(Debug)]
struct OcrStats {
total_operations: u32,
successful_operations: u32,
failed_operations: u32,
total_duration: Duration,
}
impl OcrStats {
fn success_rate(&self) -> f64 {
if self.total_operations == 0 {
0.0
} else {
self.successful_operations as f64 / self.total_operations as f64
}
}
fn average_duration(&self) -> Duration {
if self.total_operations == 0 {
Duration::ZERO
} else {
self.total_duration / self.total_operations
}
}
}
#[derive(Debug)]
struct SyncStats {
total_operations: u32,
total_files_processed: u32,
total_files_successful: u32,
}
#[test]
fn test_memory_usage_monitoring() {
let memory_monitor = MemoryMonitor::new();
// Simulate memory allocation
let mut allocations = Vec::new();
for i in 0..100 {
let allocation = vec![0u8; 1024 * 1024]; // 1MB allocation
allocations.push(allocation);
if i % 10 == 0 {
memory_monitor.record_sample();
}
}
let stats = memory_monitor.get_stats();
assert!(stats.samples.len() >= 10, "Should have memory samples");
// Check that memory usage increased
let first_sample = &stats.samples[0];
let last_sample = &stats.samples[stats.samples.len() - 1];
assert!(last_sample.heap_usage_mb >= first_sample.heap_usage_mb,
"Memory usage should increase with allocations");
}
struct MemoryMonitor {
samples: Arc<Mutex<Vec<MemoryUsage>>>,
}
impl MemoryMonitor {
fn new() -> Self {
Self {
samples: Arc::new(Mutex::new(Vec::new())),
}
}
fn record_sample(&self) {
let usage = self.get_current_memory_usage();
let mut samples = self.samples.lock().unwrap();
samples.push(usage);
}
fn get_current_memory_usage(&self) -> MemoryUsage {
// In a real implementation, this would use system APIs to get actual memory usage
// For testing, we simulate increasing usage
let samples_count = self.samples.lock().unwrap().len();
MemoryUsage {
heap_usage_mb: 50.0 + (samples_count as f64 * 5.0), // Simulated growth
rss_mb: 100.0 + (samples_count as f64 * 10.0),
timestamp: SystemTime::now(),
}
}
fn get_stats(&self) -> MemoryStats {
let samples = self.samples.lock().unwrap().clone();
MemoryStats {
samples,
}
}
}
#[derive(Debug, Clone)]
struct MemoryUsage {
heap_usage_mb: f64,
rss_mb: f64,
timestamp: SystemTime,
}
#[derive(Debug)]
struct MemoryStats {
samples: Vec<MemoryUsage>,
}
#[test]
fn test_cpu_utilization_monitoring() {
let cpu_monitor = CpuMonitor::new();
// Simulate CPU-intensive work
let start = Instant::now();
let mut counter = 0u64;
while start.elapsed() < Duration::from_millis(100) {
counter += 1;
if counter % 10000 == 0 {
cpu_monitor.record_sample();
}
}
let stats = cpu_monitor.get_stats();
assert!(!stats.samples.is_empty(), "Should have CPU samples");
// Verify that CPU usage was recorded
let avg_usage = stats.average_usage();
assert!(avg_usage >= 0.0 && avg_usage <= 100.0, "CPU usage should be between 0-100%");
}
struct CpuMonitor {
samples: Arc<Mutex<Vec<CpuUsage>>>,
}
impl CpuMonitor {
fn new() -> Self {
Self {
samples: Arc::new(Mutex::new(Vec::new())),
}
}
fn record_sample(&self) {
let usage = self.get_current_cpu_usage();
let mut samples = self.samples.lock().unwrap();
samples.push(usage);
}
fn get_current_cpu_usage(&self) -> CpuUsage {
// Simulate CPU usage measurement
let samples_count = self.samples.lock().unwrap().len();
CpuUsage {
overall_percent: 25.0 + (samples_count as f64 * 2.0).min(75.0),
user_percent: 20.0 + (samples_count as f64 * 1.5).min(60.0),
system_percent: 5.0 + (samples_count as f64 * 0.5).min(15.0),
timestamp: SystemTime::now(),
}
}
fn get_stats(&self) -> CpuStats {
let samples = self.samples.lock().unwrap().clone();
CpuStats {
samples,
}
}
}
#[derive(Debug, Clone)]
struct CpuUsage {
overall_percent: f64,
user_percent: f64,
system_percent: f64,
timestamp: SystemTime,
}
#[derive(Debug)]
struct CpuStats {
samples: Vec<CpuUsage>,
}
impl CpuStats {
fn average_usage(&self) -> f64 {
if self.samples.is_empty() {
0.0
} else {
let total: f64 = self.samples.iter().map(|s| s.overall_percent).sum();
total / self.samples.len() as f64
}
}
}
#[tokio::test]
async fn test_thread_contention_prevention() {
// Test that OCR and sync operations don't contend for resources
let shared_resource = Arc::new(Mutex::new(SharedResource::new()));
let ocr_completed = Arc::new(AtomicU32::new(0));
let sync_completed = Arc::new(AtomicU32::new(0));
let resource_clone1 = Arc::clone(&shared_resource);
let ocr_counter = Arc::clone(&ocr_completed);
// Simulate OCR work
let ocr_handle = tokio::spawn(async move {
for _ in 0..10 {
{
let mut resource = resource_clone1.lock().unwrap();
resource.ocr_operations += 1;
}
sleep(Duration::from_millis(10)).await;
ocr_counter.fetch_add(1, Ordering::Relaxed);
}
});
let resource_clone2 = Arc::clone(&shared_resource);
let sync_counter = Arc::clone(&sync_completed);
// Simulate sync work
let sync_handle = tokio::spawn(async move {
for _ in 0..10 {
{
let mut resource = resource_clone2.lock().unwrap();
resource.sync_operations += 1;
}
sleep(Duration::from_millis(10)).await;
sync_counter.fetch_add(1, Ordering::Relaxed);
}
});
// Wait for both to complete
tokio::try_join!(ocr_handle, sync_handle).unwrap();
// Verify both completed successfully
assert_eq!(ocr_completed.load(Ordering::Relaxed), 10);
assert_eq!(sync_completed.load(Ordering::Relaxed), 10);
let resource = shared_resource.lock().unwrap();
assert_eq!(resource.ocr_operations, 10);
assert_eq!(resource.sync_operations, 10);
}
#[derive(Debug)]
struct SharedResource {
ocr_operations: u32,
sync_operations: u32,
}
impl SharedResource {
fn new() -> Self {
Self {
ocr_operations: 0,
sync_operations: 0,
}
}
}
#[test]
fn test_performance_degradation_detection() {
let performance_tracker = PerformanceTracker::new();
// Record baseline performance
for _ in 0..10 {
performance_tracker.record_operation(Duration::from_millis(100), OperationType::Sync);
}
// Record degraded performance
for _ in 0..5 {
performance_tracker.record_operation(Duration::from_millis(300), OperationType::Sync);
}
let degradation = performance_tracker.detect_performance_degradation(OperationType::Sync);
assert!(degradation.is_some(), "Should detect performance degradation");
let degradation = degradation.unwrap();
assert!(degradation.severity > 1.0, "Should show significant degradation");
assert!(degradation.baseline_duration < degradation.current_duration);
}
struct PerformanceTracker {
operations: Arc<Mutex<Vec<PerformanceOperation>>>,
}
impl PerformanceTracker {
fn new() -> Self {
Self {
operations: Arc::new(Mutex::new(Vec::new())),
}
}
fn record_operation(&self, duration: Duration, op_type: OperationType) {
let mut ops = self.operations.lock().unwrap();
ops.push(PerformanceOperation {
duration,
op_type,
timestamp: SystemTime::now(),
});
}
fn detect_performance_degradation(&self, op_type: OperationType) -> Option<PerformanceDegradation> {
let ops = self.operations.lock().unwrap();
let relevant_ops: Vec<_> = ops.iter()
.filter(|op| op.op_type == op_type)
.collect();
if relevant_ops.len() < 10 {
return None; // Need more data
}
// Calculate baseline (first 10 operations)
let baseline_duration: Duration = relevant_ops.iter()
.take(10)
.map(|op| op.duration)
.sum::<Duration>() / 10;
// Calculate recent performance (last 5 operations)
let recent_duration: Duration = relevant_ops.iter()
.rev()
.take(5)
.map(|op| op.duration)
.sum::<Duration>() / 5;
let severity = recent_duration.as_millis() as f64 / baseline_duration.as_millis() as f64;
if severity > 1.5 { // 50% degradation threshold
Some(PerformanceDegradation {
baseline_duration,
current_duration: recent_duration,
severity,
operation_type: op_type,
})
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum OperationType {
Sync,
Ocr,
Database,
}
#[derive(Debug, Clone)]
struct PerformanceOperation {
duration: Duration,
op_type: OperationType,
timestamp: SystemTime,
}
#[derive(Debug)]
struct PerformanceDegradation {
baseline_duration: Duration,
current_duration: Duration,
severity: f64,
operation_type: OperationType,
}
#[tokio::test]
async fn test_backpressure_handling() {
let queue = Arc::new(Mutex::new(TaskQueue::new(10))); // Max 10 items
let processed_count = Arc::new(AtomicU32::new(0));
let queue_clone = Arc::clone(&queue);
let count_clone = Arc::clone(&processed_count);
// Start processor
let processor_handle = tokio::spawn(async move {
loop {
let task = {
let mut q = queue_clone.lock().unwrap();
q.pop()
};
match task {
Some(task) => {
// Simulate processing
sleep(Duration::from_millis(10)).await;
count_clone.fetch_add(1, Ordering::Relaxed);
},
None => {
if count_clone.load(Ordering::Relaxed) >= 20 {
break; // Stop when we've processed enough
}
sleep(Duration::from_millis(5)).await;
}
}
}
});
// Try to add more tasks than queue capacity
let mut successful_adds = 0;
let mut backpressure_hits = 0;
for i in 0..30 {
let mut queue_ref = queue.lock().unwrap();
if queue_ref.try_push(Task { id: i }) {
successful_adds += 1;
} else {
backpressure_hits += 1;
}
drop(queue_ref);
sleep(Duration::from_millis(1)).await;
}
processor_handle.await.unwrap();
assert!(backpressure_hits > 0, "Should hit backpressure when queue is full");
assert!(successful_adds > 0, "Should successfully add some tasks");
assert_eq!(processed_count.load(Ordering::Relaxed), successful_adds);
}
#[derive(Debug, Clone)]
struct Task {
id: u32,
}
struct TaskQueue {
items: Vec<Task>,
max_size: usize,
}
impl TaskQueue {
fn new(max_size: usize) -> Self {
Self {
items: Vec::new(),
max_size,
}
}
fn try_push(&mut self, task: Task) -> bool {
if self.items.len() >= self.max_size {
false // Backpressure
} else {
self.items.push(task);
true
}
}
fn pop(&mut self) -> Option<Task> {
if self.items.is_empty() {
None
} else {
Some(self.items.remove(0))
}
}
}
+622
View File
@@ -0,0 +1,622 @@
/*!
* Universal Source Sync Service Unit Tests
*
* Tests for the universal source sync service that handles:
* - Multiple source types (WebDAV, Local Folder, S3)
* - Generic sync operations and dispatching
* - File deduplication and content hashing
* - OCR queue integration
* - Error handling across source types
* - Performance optimization and metrics
*/
use std::sync::Arc;
use std::collections::HashMap;
use uuid::Uuid;
use chrono::Utc;
use serde_json::json;
use sha2::{Sha256, Digest};
use readur::{
AppState,
config::Config,
db::Database,
models::{Source, SourceType, SourceStatus, WebDAVSourceConfig, LocalFolderSourceConfig, S3SourceConfig},
source_sync::SourceSyncService,
};
/// Create a test WebDAV source
fn create_test_webdav_source() -> Source {
Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Test WebDAV".to_string(),
source_type: SourceType::WebDAV,
enabled: true,
config: json!({
"server_url": "https://cloud.example.com",
"username": "testuser",
"password": "testpass",
"watch_folders": ["/Documents"],
"file_extensions": [".pdf", ".txt"],
"auto_sync": true,
"sync_interval_minutes": 60,
"server_type": "nextcloud"
}),
status: SourceStatus::Idle,
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
/// Create a test Local Folder source
fn create_test_local_source() -> Source {
Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Test Local Folder".to_string(),
source_type: SourceType::LocalFolder,
enabled: true,
config: json!({
"paths": ["/home/user/documents"],
"recursive": true,
"follow_symlinks": false,
"auto_sync": true,
"sync_interval_minutes": 30,
"file_extensions": [".pdf", ".txt", ".jpg"]
}),
status: SourceStatus::Idle,
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
/// Create a test S3 source
fn create_test_s3_source() -> Source {
Source {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Test S3".to_string(),
source_type: SourceType::S3,
enabled: true,
config: json!({
"bucket": "test-documents",
"region": "us-east-1",
"access_key_id": "AKIATEST",
"secret_access_key": "secrettest",
"prefix": "documents/",
"auto_sync": true,
"sync_interval_minutes": 120,
"file_extensions": [".pdf", ".docx"]
}),
status: SourceStatus::Idle,
last_sync_at: None,
last_error: None,
last_error_at: None,
total_files_synced: 0,
total_files_pending: 0,
total_size_bytes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
async fn create_test_app_state() -> Arc<AppState> {
let config = Config {
database_url: "sqlite::memory:".to_string(),
server_address: "127.0.0.1:8080".to_string(),
jwt_secret: "test_secret".to_string(),
upload_dir: "/tmp/test_uploads".to_string(),
max_file_size: 10 * 1024 * 1024,
};
let db = Database::new(&config.database_url).await.unwrap();
Arc::new(AppState {
db,
config,
webdav_scheduler: None,
source_scheduler: None,
})
}
#[tokio::test]
async fn test_source_sync_service_creation() {
let state = create_test_app_state().await;
let sync_service = SourceSyncService::new(state.clone());
// Test that sync service is created successfully
// We can't access private fields, but we can test public interface
assert!(true); // Service creation succeeded
}
#[test]
fn test_source_type_detection() {
let webdav_source = create_test_webdav_source();
let local_source = create_test_local_source();
let s3_source = create_test_s3_source();
assert_eq!(webdav_source.source_type, SourceType::WebDAV);
assert_eq!(local_source.source_type, SourceType::LocalFolder);
assert_eq!(s3_source.source_type, SourceType::S3);
// Test string representations
assert_eq!(webdav_source.source_type.to_string(), "webdav");
assert_eq!(local_source.source_type.to_string(), "local_folder");
assert_eq!(s3_source.source_type.to_string(), "s3");
}
#[test]
fn test_config_parsing_webdav() {
let source = create_test_webdav_source();
let config: Result<WebDAVSourceConfig, _> = serde_json::from_value(source.config.clone());
assert!(config.is_ok(), "WebDAV config should parse successfully");
let webdav_config = config.unwrap();
assert_eq!(webdav_config.server_url, "https://cloud.example.com");
assert_eq!(webdav_config.username, "testuser");
assert!(webdav_config.auto_sync);
assert_eq!(webdav_config.sync_interval_minutes, 60);
assert_eq!(webdav_config.file_extensions.len(), 2);
}
#[test]
fn test_config_parsing_local_folder() {
let source = create_test_local_source();
let config: Result<LocalFolderSourceConfig, _> = serde_json::from_value(source.config.clone());
assert!(config.is_ok(), "Local Folder config should parse successfully");
let local_config = config.unwrap();
assert_eq!(local_config.paths.len(), 1);
assert_eq!(local_config.paths[0], "/home/user/documents");
assert!(local_config.recursive);
assert!(!local_config.follow_symlinks);
assert_eq!(local_config.sync_interval_minutes, 30);
}
#[test]
fn test_config_parsing_s3() {
let source = create_test_s3_source();
let config: Result<S3SourceConfig, _> = serde_json::from_value(source.config.clone());
assert!(config.is_ok(), "S3 config should parse successfully");
let s3_config = config.unwrap();
assert_eq!(s3_config.bucket, "test-documents");
assert_eq!(s3_config.region, "us-east-1");
assert_eq!(s3_config.prefix, "documents/");
assert_eq!(s3_config.sync_interval_minutes, 120);
}
#[test]
fn test_file_deduplication_logic() {
// Test SHA256-based file deduplication
let file_content_1 = b"This is test file content for deduplication";
let file_content_2 = b"This is different file content";
let file_content_3 = b"This is test file content for deduplication"; // Same as 1
let hash_1 = calculate_content_hash(file_content_1);
let hash_2 = calculate_content_hash(file_content_2);
let hash_3 = calculate_content_hash(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 hash format
assert_eq!(hash_1.len(), 64); // SHA256 hex string length
assert!(hash_1.chars().all(|c| c.is_ascii_hexdigit()), "Hash should be valid hex");
}
fn calculate_content_hash(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
format!("{:x}", hasher.finalize())
}
#[test]
fn test_sync_metrics_structure() {
let metrics = SyncMetrics {
source_id: Uuid::new_v4(),
source_type: SourceType::WebDAV,
files_discovered: 100,
files_downloaded: 85,
files_skipped_existing: 10,
files_skipped_extension: 3,
files_failed: 2,
total_bytes_downloaded: 50_000_000, // 50MB
sync_duration_ms: 45_000, // 45 seconds
ocr_jobs_queued: 75,
errors: vec![
SyncError {
file_path: "/Documents/failed.pdf".to_string(),
error_message: "Network timeout".to_string(),
error_code: "TIMEOUT".to_string(),
}
],
};
assert_eq!(metrics.source_type, SourceType::WebDAV);
assert_eq!(metrics.files_discovered, 100);
assert_eq!(metrics.files_downloaded, 85);
// Test calculated metrics
let total_processed = metrics.files_downloaded + metrics.files_skipped_existing +
metrics.files_skipped_extension + metrics.files_failed;
assert_eq!(total_processed, metrics.files_discovered);
let success_rate = (metrics.files_downloaded as f64 / metrics.files_discovered as f64) * 100.0;
assert_eq!(success_rate, 85.0);
// Test throughput calculation
let mb_per_second = (metrics.total_bytes_downloaded as f64 / 1_000_000.0) /
(metrics.sync_duration_ms as f64 / 1000.0);
assert!(mb_per_second > 0.0);
}
#[derive(Debug, Clone)]
struct SyncMetrics {
source_id: Uuid,
source_type: SourceType,
files_discovered: u32,
files_downloaded: u32,
files_skipped_existing: u32,
files_skipped_extension: u32,
files_failed: u32,
total_bytes_downloaded: u64,
sync_duration_ms: u64,
ocr_jobs_queued: u32,
errors: Vec<SyncError>,
}
#[derive(Debug, Clone)]
struct SyncError {
file_path: String,
error_message: String,
error_code: String,
}
#[test]
fn test_ocr_queue_integration() {
// Test OCR job creation for different file types
let test_files = vec![
("document.pdf", true), // Should queue for OCR
("image.jpg", true), // Should queue for OCR
("image.png", true), // Should queue for OCR
("text.txt", false), // Plain text, no OCR needed
("data.json", false), // JSON, no OCR needed
("archive.zip", false), // Archive, no OCR needed
];
for (filename, should_queue_ocr) in test_files {
let needs_ocr = file_needs_ocr(filename);
assert_eq!(needs_ocr, should_queue_ocr,
"OCR queueing decision wrong for: {}", filename);
}
}
fn file_needs_ocr(filename: &str) -> bool {
let ocr_extensions = vec![".pdf", ".jpg", ".jpeg", ".png", ".tiff", ".bmp"];
let extension = extract_extension(filename);
ocr_extensions.contains(&extension.as_str())
}
fn extract_extension(filename: &str) -> String {
if let Some(pos) = filename.rfind('.') {
filename[pos..].to_lowercase()
} else {
String::new()
}
}
#[test]
fn test_sync_cancellation_handling() {
// Test sync cancellation logic
use std::sync::{Arc, AtomicBool};
use std::sync::atomic::Ordering;
let cancellation_token = Arc::new(AtomicBool::new(false));
// Test normal operation
assert!(!cancellation_token.load(Ordering::Relaxed));
// Simulate cancellation request
cancellation_token.store(true, Ordering::Relaxed);
assert!(cancellation_token.load(Ordering::Relaxed));
// Test that sync would respect cancellation
let should_continue = !cancellation_token.load(Ordering::Relaxed);
assert!(!should_continue, "Sync should stop when cancelled");
// Test cancellation cleanup
cancellation_token.store(false, Ordering::Relaxed);
assert!(!cancellation_token.load(Ordering::Relaxed));
}
#[test]
fn test_error_classification() {
let test_errors = vec![
("Connection timeout", ErrorCategory::Network),
("DNS resolution failed", ErrorCategory::Network),
("HTTP 401 Unauthorized", ErrorCategory::Authentication),
("HTTP 403 Forbidden", ErrorCategory::Authentication),
("HTTP 404 Not Found", ErrorCategory::NotFound),
("HTTP 500 Internal Server Error", ErrorCategory::Server),
("Disk full", ErrorCategory::Storage),
("Permission denied", ErrorCategory::Permission),
("Invalid file format", ErrorCategory::Format),
("Unknown error", ErrorCategory::Unknown),
];
for (error_message, expected_category) in test_errors {
let category = classify_error(error_message);
assert_eq!(category, expected_category,
"Error classification failed for: {}", error_message);
}
}
#[derive(Debug, Clone, PartialEq)]
enum ErrorCategory {
Network,
Authentication,
NotFound,
Server,
Storage,
Permission,
Format,
Unknown,
}
fn classify_error(error_message: &str) -> ErrorCategory {
let msg = error_message.to_lowercase();
if msg.contains("timeout") || msg.contains("dns") || msg.contains("connection") {
ErrorCategory::Network
} else if msg.contains("401") || msg.contains("403") || msg.contains("unauthorized") || msg.contains("forbidden") {
ErrorCategory::Authentication
} else if msg.contains("404") || msg.contains("not found") {
ErrorCategory::NotFound
} else if msg.contains("500") || msg.contains("internal server") {
ErrorCategory::Server
} else if msg.contains("disk full") || msg.contains("storage") {
ErrorCategory::Storage
} else if msg.contains("permission denied") || msg.contains("access denied") {
ErrorCategory::Permission
} else if msg.contains("invalid file") || msg.contains("format") {
ErrorCategory::Format
} else {
ErrorCategory::Unknown
}
}
#[test]
fn test_retry_strategy() {
// Test retry strategy for different error types
let retry_configs = vec![
(ErrorCategory::Network, 3, true), // Retry network errors
(ErrorCategory::Server, 2, true), // Retry server errors
(ErrorCategory::Authentication, 0, false), // Don't retry auth errors
(ErrorCategory::NotFound, 0, false), // Don't retry not found
(ErrorCategory::Permission, 0, false), // Don't retry permission errors
(ErrorCategory::Format, 0, false), // Don't retry format errors
];
for (error_category, expected_retries, should_retry) in retry_configs {
let retry_count = get_retry_count_for_error(&error_category);
let will_retry = retry_count > 0;
assert_eq!(retry_count, expected_retries);
assert_eq!(will_retry, should_retry,
"Retry decision wrong for: {:?}", error_category);
}
}
fn get_retry_count_for_error(error_category: &ErrorCategory) -> u32 {
match error_category {
ErrorCategory::Network => 3,
ErrorCategory::Server => 2,
ErrorCategory::Storage => 1,
_ => 0, // Don't retry other types
}
}
#[test]
fn test_sync_performance_monitoring() {
// Test performance monitoring metrics
let performance_data = SyncPerformanceData {
throughput_mbps: 5.2,
files_per_second: 2.8,
avg_file_size_mb: 1.8,
memory_usage_mb: 45.6,
cpu_usage_percent: 12.3,
network_latency_ms: 85,
error_rate_percent: 2.1,
};
// Test performance thresholds
assert!(performance_data.throughput_mbps > 1.0, "Throughput should be reasonable");
assert!(performance_data.files_per_second > 0.5, "File processing rate should be reasonable");
assert!(performance_data.memory_usage_mb < 500.0, "Memory usage should be reasonable");
assert!(performance_data.cpu_usage_percent < 80.0, "CPU usage should be reasonable");
assert!(performance_data.network_latency_ms < 1000, "Network latency should be reasonable");
assert!(performance_data.error_rate_percent < 10.0, "Error rate should be low");
}
#[derive(Debug, Clone)]
struct SyncPerformanceData {
throughput_mbps: f64,
files_per_second: f64,
avg_file_size_mb: f64,
memory_usage_mb: f64,
cpu_usage_percent: f64,
network_latency_ms: u64,
error_rate_percent: f64,
}
#[test]
fn test_source_priority_handling() {
// Test priority-based source processing
let sources = vec![
(SourceType::LocalFolder, 1), // Highest priority (local is fastest)
(SourceType::WebDAV, 2), // Medium priority
(SourceType::S3, 3), // Lower priority (remote with potential costs)
];
let mut sorted_sources = sources.clone();
sorted_sources.sort_by_key(|(_, priority)| *priority);
assert_eq!(sorted_sources[0].0, SourceType::LocalFolder);
assert_eq!(sorted_sources[1].0, SourceType::WebDAV);
assert_eq!(sorted_sources[2].0, SourceType::S3);
// Test that local sources are processed first
let local_priority = get_source_priority(&SourceType::LocalFolder);
let webdav_priority = get_source_priority(&SourceType::WebDAV);
let s3_priority = get_source_priority(&SourceType::S3);
assert!(local_priority < webdav_priority);
assert!(webdav_priority < s3_priority);
}
fn get_source_priority(source_type: &SourceType) -> u32 {
match source_type {
SourceType::LocalFolder => 1, // Highest priority
SourceType::WebDAV => 2, // Medium priority
SourceType::S3 => 3, // Lower priority
}
}
#[test]
fn test_concurrent_sync_protection() {
use std::sync::{Arc, Mutex};
use std::collections::HashSet;
// Test that only one sync per source can run at a time
let active_syncs: Arc<Mutex<HashSet<Uuid>>> = Arc::new(Mutex::new(HashSet::new()));
let source_id_1 = Uuid::new_v4();
let source_id_2 = Uuid::new_v4();
// Test adding first sync
{
let mut syncs = active_syncs.lock().unwrap();
assert!(syncs.insert(source_id_1));
}
// Test adding second sync (different source)
{
let mut syncs = active_syncs.lock().unwrap();
assert!(syncs.insert(source_id_2));
}
// Test preventing duplicate sync for same source
{
let mut syncs = active_syncs.lock().unwrap();
assert!(!syncs.insert(source_id_1)); // Should fail
}
// Test cleanup after sync completion
{
let mut syncs = active_syncs.lock().unwrap();
assert!(syncs.remove(&source_id_1));
assert!(!syncs.remove(&source_id_1)); // Should fail second time
}
}
#[test]
fn test_sync_state_transitions() {
// Test valid state transitions during sync
let valid_transitions = vec![
(SourceStatus::Idle, SourceStatus::Syncing),
(SourceStatus::Syncing, SourceStatus::Idle),
(SourceStatus::Syncing, SourceStatus::Error),
(SourceStatus::Error, SourceStatus::Syncing),
(SourceStatus::Error, SourceStatus::Idle),
];
for (from_state, to_state) in valid_transitions {
assert!(is_valid_state_transition(&from_state, &to_state),
"Invalid transition from {:?} to {:?}", from_state, to_state);
}
// Test invalid transitions
let invalid_transitions = vec![
(SourceStatus::Idle, SourceStatus::Error), // Can't go directly to error without syncing
];
for (from_state, to_state) in invalid_transitions {
assert!(!is_valid_state_transition(&from_state, &to_state),
"Should not allow transition from {:?} to {:?}", from_state, to_state);
}
}
fn is_valid_state_transition(from: &SourceStatus, to: &SourceStatus) -> bool {
match (from, to) {
(SourceStatus::Idle, SourceStatus::Syncing) => true,
(SourceStatus::Syncing, SourceStatus::Idle) => true,
(SourceStatus::Syncing, SourceStatus::Error) => true,
(SourceStatus::Error, SourceStatus::Syncing) => true,
(SourceStatus::Error, SourceStatus::Idle) => true,
_ => false,
}
}
#[test]
fn test_bandwidth_limiting() {
// Test bandwidth limiting calculations
let bandwidth_limiter = BandwidthLimiter {
max_mbps: 10.0,
current_usage_mbps: 8.5,
burst_allowance_mb: 50.0,
current_burst_mb: 25.0,
};
// Test if download should be throttled
let should_throttle = bandwidth_limiter.should_throttle_download(5.0); // 5MB download
assert!(!should_throttle, "Small download within burst allowance should not be throttled");
let should_throttle_large = bandwidth_limiter.should_throttle_download(30.0); // 30MB download
assert!(should_throttle_large, "Large download exceeding burst should be throttled");
// Test delay calculation
let delay_ms = bandwidth_limiter.calculate_delay_ms(1_000_000); // 1MB
assert!(delay_ms > 0, "Should have some delay when near bandwidth limit");
}
#[derive(Debug, Clone)]
struct BandwidthLimiter {
max_mbps: f64,
current_usage_mbps: f64,
burst_allowance_mb: f64,
current_burst_mb: f64,
}
impl BandwidthLimiter {
fn should_throttle_download(&self, download_size_mb: f64) -> bool {
self.current_usage_mbps >= self.max_mbps * 0.8 && // Near limit
download_size_mb > (self.burst_allowance_mb - self.current_burst_mb)
}
fn calculate_delay_ms(&self, bytes: u64) -> u64 {
if self.current_usage_mbps < self.max_mbps * 0.8 {
return 0; // No throttling needed
}
let mb = bytes as f64 / 1_000_000.0;
let ideal_time_seconds = mb / self.max_mbps;
(ideal_time_seconds * 1000.0) as u64
}
}
+492
View File
@@ -0,0 +1,492 @@
/*!
* 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},
webdav_service::{WebDAVService, WebDAVConfig, WebDAVFile, CrawlEstimate},
};
/// 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: "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: "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, "nextcloud");
}
#[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, "nextcloud");
}
#[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 {
path: "/Documents/test.pdf".to_string(),
etag: "abc123".to_string(),
size: 1024,
last_modified: Utc::now(),
content_type: "application/pdf".to_string(),
};
assert_eq!(webdav_file.path, "/Documents/test.pdf");
assert_eq!(webdav_file.etag, "abc123");
assert_eq!(webdav_file.size, 1024);
assert_eq!(webdav_file.content_type, "application/pdf");
// Test filename extraction
let filename = webdav_file.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_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 = CrawlEstimate {
folders: vec![
json!({
"path": "/Documents",
"file_count": 10,
"supported_files": 8,
"estimated_time_hours": 0.5,
"size_mb": 50.0
}),
json!({
"path": "/Photos",
"file_count": 100,
"supported_files": 90,
"estimated_time_hours": 2.0,
"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: i32 = estimate.folders.iter()
.map(|f| f["file_count"].as_i64().unwrap() as i32)
.sum();
assert_eq!(calculated_files, estimate.total_files);
let calculated_supported: i32 = estimate.folders.iter()
.map(|f| f["supported_files"].as_i64().unwrap() as i32)
.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: server_type.to_string(),
};
if is_supported {
assert!(["nextcloud", "owncloud"].contains(&config.server_type.as_str()));
} else {
assert!(!["nextcloud", "owncloud"].contains(&config.server_type.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: "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: "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: "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<Mutex<HashSet<Uuid>>> = 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
}