Files
readur/tests/integration_stop_sync_functionality_tests.rs

411 lines
12 KiB
Rust

/*!
* 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<AppState> {
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<Uuid, bool> = 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");
}