602 lines
18 KiB
Rust
602 lines
18 KiB
Rust
/*!
|
|
* 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
|
|
} |