/*! * Stop/Cancel Sync Functionality Tests * * Tests for the new stop/cancel sync functionality including: * - API endpoint for stopping sync * - Source scheduler cancellation support * - Cancellation token propagation * - Graceful sync termination * - OCR continuation after sync cancellation */ use std::sync::Arc; use std::time::Duration; use uuid::Uuid; use chrono::Utc; use serde_json::json; use tokio::time::sleep; use readur::{ AppState, config::Config, db::Database, models::{Source, SourceType, SourceStatus, WebDAVSourceConfig}, scheduling::source_scheduler::SourceScheduler, }; /// Create a test app state async fn create_test_app_state() -> Arc { let database_url = std::env::var("TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string()); let config = Config { database_url, 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(), user_watch_base_dir: "./user_watch".to_string(), enable_per_user_watch: false, 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(), oidc_enabled: false, oidc_client_id: None, oidc_client_secret: None, oidc_issuer_url: None, oidc_redirect_uri: None, s3_enabled: false, s3_config: None, }; let db = Database::new(&config.database_url).await.unwrap(); // Create file service let storage_config = readur::storage::StorageConfig::Local { upload_path: config.upload_path.clone() }; let storage_backend = readur::storage::factory::create_storage_backend(storage_config).await.unwrap(); let file_service = Arc::new(readur::services::file_service::FileService::with_storage(config.upload_path.clone(), storage_backend)); let queue_service = Arc::new(readur::ocr::queue::OcrQueueService::new( db.clone(), db.pool.clone(), 4, file_service.clone(), )); Arc::new(AppState { db: db.clone(), config, file_service, webdav_scheduler: None, source_scheduler: None, queue_service, oidc_client: None, sync_progress_tracker: std::sync::Arc::new(readur::services::sync_progress_tracker::SyncProgressTracker::new()), user_watch_service: None, webdav_metrics_collector: None, }) } /// Create a test source for stop sync testing fn create_test_source_for_stop_sync(user_id: Uuid) -> Source { Source { id: Uuid::new_v4(), user_id, name: "Test Source for Stop Sync".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(), validation_status: None, last_validation_at: None, validation_score: None, validation_issues: None, } } #[tokio::test] async fn test_source_scheduler_creation_with_cancellation() { 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_stop_sync_api_endpoint_structure() { // Test that the API endpoint structure is correct // Verify that the stop sync endpoint would be: // POST /api/sources/{id}/sync/stop // This test ensures the endpoint structure follows REST conventions let base_path = "/api/sources"; let source_id = "test-id"; let sync_action = "sync"; let stop_action = "stop"; let trigger_endpoint = format!("{}/{}/{}", base_path, source_id, sync_action); let stop_endpoint = format!("{}/{}/{}/{}", base_path, source_id, sync_action, stop_action); assert_eq!(trigger_endpoint, "/api/sources/test-id/sync"); assert_eq!(stop_endpoint, "/api/sources/test-id/sync/stop"); } #[test] fn test_source_status_transitions_for_cancellation() { // Test valid status transitions when cancelling sync // Initial state: Source is syncing let mut status = SourceStatus::Syncing; // After cancellation: Source should be idle status = SourceStatus::Idle; assert_eq!(status, SourceStatus::Idle); // Test invalid transitions let error_status = SourceStatus::Error; // Error status should not be used for user-initiated cancellation assert_ne!(error_status, SourceStatus::Idle); } #[test] fn test_cancellation_reasons() { // Test different cancellation scenarios #[derive(Debug, PartialEq)] enum CancellationReason { UserRequested, ServerShutdown, NetworkError, Timeout, } let user_cancellation = CancellationReason::UserRequested; let server_cancellation = CancellationReason::ServerShutdown; // User-requested cancellation should be different from server shutdown assert_ne!(user_cancellation, server_cancellation); // Both should result in sync being stopped let should_stop = match user_cancellation { CancellationReason::UserRequested => true, CancellationReason::ServerShutdown => true, CancellationReason::NetworkError => false, // Might retry CancellationReason::Timeout => false, // Might retry }; assert!(should_stop); } #[test] fn test_cancellation_token_behavior() { use tokio_util::sync::CancellationToken; // Test cancellation token creation and usage let token = CancellationToken::new(); // Initially not cancelled assert!(!token.is_cancelled()); // After cancellation token.cancel(); assert!(token.is_cancelled()); // Child tokens should also be cancelled let child_token = token.child_token(); assert!(child_token.is_cancelled()); } #[tokio::test] async fn test_graceful_cancellation_behavior() { // Test that cancellation allows current operations to complete gracefully use tokio_util::sync::CancellationToken; use std::sync::atomic::{AtomicU32, Ordering}; let token = CancellationToken::new(); let work_completed = Arc::new(AtomicU32::new(0)); let work_completed_clone = work_completed.clone(); let token_clone = token.clone(); // Simulate work that checks for cancellation let work_handle = tokio::spawn(async move { for i in 1..=10 { // Check for cancellation before each unit of work if token_clone.is_cancelled() { // Complete current work item gracefully work_completed_clone.store(i - 1, Ordering::Relaxed); break; } // Simulate work sleep(Duration::from_millis(10)).await; work_completed_clone.store(i, Ordering::Relaxed); } }); // Let some work complete sleep(Duration::from_millis(30)).await; // Cancel the work token.cancel(); // Wait for graceful shutdown work_handle.await.unwrap(); let completed = work_completed.load(Ordering::Relaxed); // Should have completed some work but not all assert!(completed > 0, "Some work should have been completed"); assert!(completed < 10, "Not all work should have been completed"); } #[test] fn test_error_messages_for_stop_sync() { // Test appropriate error messages for different stop sync scenarios #[derive(Debug, PartialEq)] enum StopSyncError { SourceNotFound, NotCurrentlySyncing, PermissionDenied, InternalError, } // Test error mapping let test_cases = vec![ (404, StopSyncError::SourceNotFound), (409, StopSyncError::NotCurrentlySyncing), (403, StopSyncError::PermissionDenied), (500, StopSyncError::InternalError), ]; for (status_code, expected_error) in test_cases { let actual_error = match status_code { 404 => StopSyncError::SourceNotFound, 409 => StopSyncError::NotCurrentlySyncing, 403 => StopSyncError::PermissionDenied, _ => StopSyncError::InternalError, }; assert_eq!(actual_error, expected_error); } } #[test] fn test_ocr_continuation_after_sync_cancellation() { // Test that OCR continues processing even after sync is cancelled #[derive(Debug, PartialEq)] enum ProcessingStatus { SyncActive, SyncCancelled, OcrContinuing, OcrCompleted, } let mut status = ProcessingStatus::SyncActive; // Sync is cancelled status = ProcessingStatus::SyncCancelled; assert_eq!(status, ProcessingStatus::SyncCancelled); // OCR should continue status = ProcessingStatus::OcrContinuing; assert_eq!(status, ProcessingStatus::OcrContinuing); // OCR can complete independently status = ProcessingStatus::OcrCompleted; assert_eq!(status, ProcessingStatus::OcrCompleted); } #[test] fn test_frontend_button_states() { // Test that frontend button states are correct #[derive(Debug, PartialEq)] enum ButtonState { ShowStart, ShowStop, ShowLoading, Disabled, } #[derive(Debug, PartialEq)] enum SourceStatus { Idle, Syncing, Error, } let get_button_state = |status: &SourceStatus, enabled: bool| -> ButtonState { if !enabled { return ButtonState::Disabled; } match status { SourceStatus::Idle => ButtonState::ShowStart, SourceStatus::Syncing => ButtonState::ShowStop, SourceStatus::Error => ButtonState::ShowStart, } }; // Test different scenarios assert_eq!(get_button_state(&SourceStatus::Idle, true), ButtonState::ShowStart); assert_eq!(get_button_state(&SourceStatus::Syncing, true), ButtonState::ShowStop); assert_eq!(get_button_state(&SourceStatus::Error, true), ButtonState::ShowStart); assert_eq!(get_button_state(&SourceStatus::Idle, false), ButtonState::Disabled); } #[tokio::test] async fn test_stop_sync_scheduler_method() { let state = create_test_app_state().await; let scheduler = SourceScheduler::new(state.clone()); // Test stopping a non-existent sync let non_existent_id = Uuid::new_v4(); let result = scheduler.stop_sync(non_existent_id).await; // Should return error for non-existent sync assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("No running sync found")); } #[test] fn test_cancellation_cleanup() { // Test that cancellation properly cleans up resources use std::collections::HashMap; use uuid::Uuid; let mut running_syncs: HashMap = HashMap::new(); let source_id = Uuid::new_v4(); // Start sync running_syncs.insert(source_id, true); assert!(running_syncs.contains_key(&source_id)); // Cancel and cleanup running_syncs.remove(&source_id); assert!(!running_syncs.contains_key(&source_id)); } #[test] fn test_performance_impact_of_cancellation_checks() { // Test that cancellation checks don't significantly impact performance use std::time::Instant; use tokio_util::sync::CancellationToken; let token = CancellationToken::new(); let start = Instant::now(); // Simulate many cancellation checks for _ in 0..10000 { let _is_cancelled = token.is_cancelled(); } let duration = start.elapsed(); // Should complete quickly (less than 1ms for 10k checks) assert!(duration.as_millis() < 10, "Cancellation checks should be fast"); }