mirror of
https://github.com/readur/readur.git
synced 2026-05-08 07:20:23 -05:00
feat(unit): add unit tests for sources
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(®ion)
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user