mirror of
https://github.com/readur/readur.git
synced 2025-12-29 18:40:21 -06:00
411 lines
12 KiB
Rust
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");
|
|
} |