- Fix RRULE BYDAY filtering for daily frequency events (Tether sync weekdays only) - Fix timezone transfer in recurring event expansion - Add comprehensive timezone-aware iCal generation - Add extensive test suite for recurrence and timezone functionality - Update dependencies and configuration examples - Implement cleanup logic for orphaned events - Add detailed import plan documentation This completes the core import functionality with proper timezone handling, RRULE parsing, and duplicate prevention mechanisms.
556 lines
20 KiB
Rust
556 lines
20 KiB
Rust
use caldav_sync::{Config, CalDavResult};
|
|
|
|
#[cfg(test)]
|
|
mod config_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_config() -> CalDavResult<()> {
|
|
let config = Config::default();
|
|
assert_eq!(config.server.url, "https://caldav.example.com");
|
|
assert_eq!(config.calendar.name, "calendar");
|
|
assert_eq!(config.sync.interval, 300);
|
|
config.validate()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_validation() -> CalDavResult<()> {
|
|
let mut config = Config::default();
|
|
|
|
// Should fail with empty credentials
|
|
assert!(config.validate().is_err());
|
|
|
|
config.server.username = "test_user".to_string();
|
|
config.server.password = "test_pass".to_string();
|
|
|
|
// Should succeed now
|
|
assert!(config.validate().is_ok());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod error_tests {
|
|
use caldav_sync::{CalDavError, CalDavResult};
|
|
|
|
#[test]
|
|
fn test_error_retryable() {
|
|
let network_error = CalDavError::Network(
|
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
|
);
|
|
assert!(network_error.is_retryable());
|
|
|
|
let auth_error = CalDavError::Authentication("Invalid credentials".to_string());
|
|
assert!(!auth_error.is_retryable());
|
|
|
|
let config_error = CalDavError::Config("Missing URL".to_string());
|
|
assert!(!config_error.is_retryable());
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_classification() {
|
|
let auth_error = CalDavError::Authentication("Invalid".to_string());
|
|
assert!(auth_error.is_auth_error());
|
|
|
|
let config_error = CalDavError::Config("Invalid".to_string());
|
|
assert!(config_error.is_config_error());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod event_tests {
|
|
use caldav_sync::event::{Event, EventStatus, EventType};
|
|
use chrono::{DateTime, Utc, NaiveDate};
|
|
|
|
#[test]
|
|
fn test_event_creation() {
|
|
let start = Utc::now();
|
|
let end = start + chrono::Duration::hours(1);
|
|
let event = Event::new("Test Event".to_string(), start, end);
|
|
|
|
assert_eq!(event.summary, "Test Event");
|
|
assert_eq!(event.start, start);
|
|
assert_eq!(event.end, end);
|
|
assert!(!event.all_day);
|
|
assert_eq!(event.status, EventStatus::Confirmed);
|
|
assert_eq!(event.event_type, EventType::Public);
|
|
}
|
|
|
|
#[test]
|
|
fn test_all_day_event() {
|
|
let date = NaiveDate::from_ymd_opt(2023, 12, 25).unwrap();
|
|
let event = Event::new_all_day("Christmas".to_string(), date);
|
|
|
|
assert_eq!(event.summary, "Christmas");
|
|
assert!(event.all_day);
|
|
assert!(event.occurs_on(date));
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_to_ical() -> caldav_sync::CalDavResult<()> {
|
|
let event = Event::new(
|
|
"Meeting".to_string(),
|
|
DateTime::from_naive_utc_and_offset(
|
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
|
Utc
|
|
),
|
|
DateTime::from_naive_utc_and_offset(
|
|
chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(),
|
|
Utc
|
|
),
|
|
);
|
|
|
|
let ical = event.to_ical()?;
|
|
assert!(ical.contains("SUMMARY:Meeting"));
|
|
assert!(ical.contains("DTSTART:20231225T100000Z"));
|
|
assert!(ical.contains("DTEND:20231225T110000Z"));
|
|
assert!(ical.contains("BEGIN:VCALENDAR"));
|
|
assert!(ical.contains("END:VCALENDAR"));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod timezone_tests {
|
|
use caldav_sync::timezone::TimezoneHandler;
|
|
|
|
#[test]
|
|
fn test_timezone_handler_creation() -> CalDavResult<()> {
|
|
let handler = TimezoneHandler::new("UTC")?;
|
|
assert_eq!(handler.default_timezone(), "UTC");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_timezone_validation() {
|
|
assert!(TimezoneHandler::validate_timezone("UTC"));
|
|
assert!(TimezoneHandler::validate_timezone("America/New_York"));
|
|
assert!(TimezoneHandler::validate_timezone("Europe/London"));
|
|
assert!(!TimezoneHandler::validate_timezone("Invalid/Timezone"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ical_formatting() -> CalDavResult<()> {
|
|
let handler = TimezoneHandler::default();
|
|
let dt = DateTime::from_naive_utc_and_offset(
|
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
|
Utc
|
|
);
|
|
|
|
let ical_utc = handler.format_ical_datetime(dt, false)?;
|
|
assert_eq!(ical_utc, "20231225T100000Z");
|
|
|
|
let ical_date = handler.format_ical_date(dt);
|
|
assert_eq!(ical_date, "20231225");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod filter_tests {
|
|
use caldav_sync::calendar_filter::{
|
|
CalendarFilter, FilterRule, DateRangeFilter, KeywordFilter,
|
|
EventTypeFilter, EventStatusFilter, FilterBuilder
|
|
};
|
|
use caldav_sync::event::{Event, EventStatus, EventType};
|
|
use chrono::{DateTime, Utc};
|
|
|
|
#[test]
|
|
fn test_date_range_filter() {
|
|
let start = DateTime::from_naive_utc_and_offset(
|
|
chrono::NaiveDateTime::parse_from_str("20231225T000000", "%Y%m%dT%H%M%S").unwrap(),
|
|
Utc
|
|
);
|
|
let end = DateTime::from_naive_utc_and_offset(
|
|
chrono::NaiveDateTime::parse_from_str("20231225T235959", "%Y%m%dT%H%M%S").unwrap(),
|
|
Utc
|
|
);
|
|
|
|
let filter = DateRangeFilter::new(start, end);
|
|
|
|
let event_start = DateTime::from_naive_utc_and_offset(
|
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
|
Utc
|
|
);
|
|
let event = Event::new("Test".to_string(), event_start, event_start + chrono::Duration::hours(1));
|
|
assert!(filter.matches_event(&event));
|
|
|
|
let event_outside = Event::new(
|
|
"Test".to_string(),
|
|
start - chrono::Duration::days(1),
|
|
start - chrono::Duration::hours(23),
|
|
);
|
|
assert!(!filter_outside.matches_event(&event_outside));
|
|
}
|
|
|
|
#[test]
|
|
fn test_keyword_filter() {
|
|
let filter = KeywordFilter::new(vec!["meeting".to_string(), "important".to_string()], false);
|
|
|
|
let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
|
assert!(filter.matches_event(&event1));
|
|
|
|
let event2 = Event::new("Lunch".to_string(), Utc::now(), Utc::now());
|
|
assert!(!filter.matches_event(&event2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_calendar_filter() {
|
|
let mut filter = CalendarFilter::new(true); // OR logic
|
|
filter.add_rule(FilterRule::Keywords(KeywordFilter::new(vec!["meeting".to_string()], false)));
|
|
filter.add_rule(FilterRule::EventStatus(EventStatusFilter::new(vec![EventStatus::Cancelled])));
|
|
|
|
let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
|
assert!(filter.matches_event(&event1)); // Matches keyword
|
|
|
|
let mut event2 = Event::new("Holiday".to_string(), Utc::now(), Utc::now());
|
|
event2.status = EventStatus::Cancelled;
|
|
assert!(filter.matches_event(&event2)); // Matches status
|
|
|
|
let event3 = Event::new("Lunch".to_string(), Utc::now(), Utc::now());
|
|
assert!(!filter.matches_event(&event3)); // Matches neither
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_builder() {
|
|
let filter = FilterBuilder::new()
|
|
.match_any(false) // AND logic
|
|
.keywords(vec!["meeting".to_string()])
|
|
.event_types(vec![EventType::Public])
|
|
.build();
|
|
|
|
let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
|
assert!(filter.matches_event(&event)); // Matches both conditions
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod live_caldav_tests {
|
|
use caldav_sync::Config;
|
|
use caldav_sync::minicaldav_client::RealCalDavClient;
|
|
use caldav_sync::event::Event;
|
|
use chrono::{DateTime, Utc, Duration};
|
|
use tokio;
|
|
use std::path::PathBuf;
|
|
|
|
/// Test basic CRUD operations on the import calendar using the test configuration
|
|
#[tokio::test]
|
|
async fn test_create_update_delete_event() -> Result<(), Box<dyn std::error::Error>> {
|
|
println!("🧪 Starting CRUD test with import calendar...");
|
|
|
|
// Load test configuration
|
|
let config_path = PathBuf::from("config-test-import.toml");
|
|
let config = Config::from_file(&config_path)?;
|
|
|
|
// Validate configuration
|
|
config.validate()?;
|
|
|
|
// Create CalDAV client for target server (Nextcloud)
|
|
let import_config = config.get_import_config().ok_or("No import configuration found")?;
|
|
let target_client = RealCalDavClient::new(
|
|
&import_config.target_server.url,
|
|
&import_config.target_server.username,
|
|
&import_config.target_server.password,
|
|
).await?;
|
|
|
|
// Build target calendar URL
|
|
let target_calendar_url = format!("{}/", import_config.target_server.url.trim_end_matches('/'));
|
|
|
|
// Validate target calendar
|
|
let is_valid = target_client.validate_target_calendar(&target_calendar_url).await?;
|
|
assert!(is_valid, "Target calendar should be accessible");
|
|
println!("✅ Target calendar is accessible");
|
|
|
|
// Create test event for today
|
|
let now = Utc::now();
|
|
let today_start = now.date_naive().and_hms_opt(10, 0, 0).unwrap().and_utc();
|
|
let today_end = today_start + Duration::hours(1);
|
|
|
|
let test_uid = format!("test-event-{}", now.timestamp());
|
|
let mut test_event = Event::new(
|
|
format!("Test Event {}", test_uid),
|
|
today_start,
|
|
today_end,
|
|
);
|
|
test_event.uid = test_uid.clone();
|
|
test_event.description = Some("This is a test event for CRUD operations".to_string());
|
|
test_event.location = Some("Test Location".to_string());
|
|
|
|
println!("📝 Creating test event: {}", test_event.summary);
|
|
|
|
// Convert event to iCalendar format
|
|
let ical_data = test_event.to_ical()?;
|
|
|
|
// Test 1: Create event
|
|
let create_result = target_client.put_event(
|
|
&target_calendar_url,
|
|
&test_uid,
|
|
&ical_data,
|
|
None // No ETag for creation
|
|
).await;
|
|
|
|
match create_result {
|
|
Ok(_) => println!("✅ Event created successfully"),
|
|
Err(e) => {
|
|
println!("❌ Failed to create event: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
// Wait a moment to ensure the event is processed
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
|
|
// Test 2: Verify event exists
|
|
println!("🔍 Verifying event exists...");
|
|
let etag_result = target_client.get_event_etag(&target_calendar_url, &test_uid).await;
|
|
|
|
let original_etag = match etag_result {
|
|
Ok(Some(etag)) => {
|
|
println!("✅ Event verified, ETag: {}", etag);
|
|
etag
|
|
}
|
|
Ok(None) => {
|
|
println!("❌ Event not found after creation");
|
|
return Err("Event not found after creation".into());
|
|
}
|
|
Err(e) => {
|
|
println!("❌ Failed to verify event: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
// Test 3: Update event (change date to tomorrow)
|
|
println!("📝 Updating event for tomorrow...");
|
|
let tomorrow_start = today_start + Duration::days(1);
|
|
let tomorrow_end = tomorrow_start + Duration::hours(1);
|
|
|
|
test_event.start = tomorrow_start;
|
|
test_event.end = tomorrow_end;
|
|
test_event.summary = format!("Test Event {} (Updated for Tomorrow)", test_uid);
|
|
test_event.description = Some("This event has been updated to tomorrow".to_string());
|
|
test_event.sequence += 1; // Increment sequence for update
|
|
|
|
// Convert updated event to iCalendar format
|
|
let updated_ical_data = test_event.to_ical()?;
|
|
|
|
let update_result = target_client.put_event(
|
|
&target_calendar_url,
|
|
&test_uid,
|
|
&updated_ical_data,
|
|
Some(&original_etag) // Use ETag for update
|
|
).await;
|
|
|
|
match update_result {
|
|
Ok(_) => println!("✅ Event updated successfully"),
|
|
Err(e) => {
|
|
println!("❌ Failed to update event: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
// Wait a moment to ensure the update is processed
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
|
|
// Test 4: Verify event was updated (ETag should change)
|
|
println!("🔍 Verifying event update...");
|
|
let new_etag_result = target_client.get_event_etag(&target_calendar_url, &test_uid).await;
|
|
|
|
match new_etag_result {
|
|
Ok(Some(new_etag)) => {
|
|
if new_etag != original_etag {
|
|
println!("✅ Event updated, new ETag: {}", new_etag);
|
|
} else {
|
|
println!("⚠️ Event ETag didn't change after update");
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
println!("❌ Event not found after update");
|
|
return Err("Event not found after update".into());
|
|
}
|
|
Err(e) => {
|
|
println!("❌ Failed to verify updated event: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
// Test 5: Delete event
|
|
println!("🗑️ Deleting event...");
|
|
let delete_result = target_client.delete_event(
|
|
&target_calendar_url,
|
|
&test_uid,
|
|
None // No ETag for deletion (let server handle it)
|
|
).await;
|
|
|
|
match delete_result {
|
|
Ok(_) => println!("✅ Event deleted successfully"),
|
|
Err(e) => {
|
|
println!("❌ Failed to delete event: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
// Wait a moment to ensure the deletion is processed
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
|
|
// Test 6: Verify event was deleted
|
|
println!("🔍 Verifying event deletion...");
|
|
let final_check = target_client.get_event_etag(&target_calendar_url, &test_uid).await;
|
|
|
|
match final_check {
|
|
Ok(None) => println!("✅ Event successfully deleted"),
|
|
Ok(Some(etag)) => {
|
|
println!("❌ Event still exists after deletion, ETag: {}", etag);
|
|
return Err("Event still exists after deletion".into());
|
|
}
|
|
Err(e) => {
|
|
println!("❌ Failed to verify deletion: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
println!("🎉 All CRUD operations completed successfully!");
|
|
Ok(())
|
|
}
|
|
|
|
/// Test HTTP error handling by attempting to delete a non-existent event
|
|
#[tokio::test]
|
|
async fn test_delete_nonexistent_event() -> Result<(), Box<dyn std::error::Error>> {
|
|
println!("🧪 Testing deletion of non-existent event...");
|
|
|
|
// Load test configuration
|
|
let config_path = PathBuf::from("config-test-import.toml");
|
|
let config = Config::from_file(&config_path)?;
|
|
|
|
// Create CalDAV client for target server
|
|
let import_config = config.get_import_config().ok_or("No import configuration found")?;
|
|
let target_client = RealCalDavClient::new(
|
|
&import_config.target_server.url,
|
|
&import_config.target_server.username,
|
|
&import_config.target_server.password,
|
|
).await?;
|
|
|
|
// Build target calendar URL
|
|
let target_calendar_url = format!("{}/", import_config.target_server.url.trim_end_matches('/'));
|
|
|
|
// Try to delete a non-existent event
|
|
let fake_uid = "non-existent-event-12345";
|
|
println!("🗑️ Testing deletion of non-existent event: {}", fake_uid);
|
|
|
|
let delete_result = target_client.delete_event(
|
|
&target_calendar_url,
|
|
fake_uid,
|
|
None
|
|
).await;
|
|
|
|
match delete_result {
|
|
Ok(_) => {
|
|
println!("✅ Non-existent event deletion handled gracefully (idempotent)");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
println!("❌ Failed to handle non-existent event deletion gracefully: {}", e);
|
|
Err(e.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test event existence checking
|
|
#[tokio::test]
|
|
async fn test_event_existence_check() -> Result<(), Box<dyn std::error::Error>> {
|
|
println!("🧪 Testing event existence check...");
|
|
|
|
// Load test configuration
|
|
let config_path = PathBuf::from("config-test-import.toml");
|
|
let config = Config::from_file(&config_path)?;
|
|
|
|
// Create CalDAV client for target server
|
|
let import_config = config.get_import_config().ok_or("No import configuration found")?;
|
|
let target_client = RealCalDavClient::new(
|
|
&import_config.target_server.url,
|
|
&import_config.target_server.username,
|
|
&import_config.target_server.password,
|
|
).await?;
|
|
|
|
// Build target calendar URL
|
|
let target_calendar_url = format!("{}/", import_config.target_server.url.trim_end_matches('/'));
|
|
|
|
// Test non-existent event
|
|
let fake_uid = "non-existent-event-67890";
|
|
let fake_event_url = format!("{}{}.ics", target_calendar_url, fake_uid);
|
|
|
|
println!("🔍 Testing existence check for non-existent event: {}", fake_uid);
|
|
|
|
let existence_result = target_client.check_event_exists(&fake_event_url).await;
|
|
|
|
match existence_result {
|
|
Ok(_) => {
|
|
println!("❌ Non-existent event reported as existing");
|
|
Err("Non-existent event reported as existing".into())
|
|
}
|
|
Err(e) => {
|
|
println!("✅ Non-existent event correctly reported as missing: {}", e);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod integration_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_library_initialization() -> CalDavResult<()> {
|
|
caldav_sync::init()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_version() {
|
|
assert!(!caldav_sync::VERSION.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_full_workflow() -> CalDavResult<()> {
|
|
// Initialize library
|
|
caldav_sync::init()?;
|
|
|
|
// Create configuration
|
|
let config = Config::default();
|
|
|
|
// Validate configuration
|
|
config.validate()?;
|
|
|
|
// Create some test events
|
|
let event1 = caldav_sync::event::Event::new(
|
|
"Test Meeting".to_string(),
|
|
Utc::now(),
|
|
Utc::now() + chrono::Duration::hours(1),
|
|
);
|
|
|
|
let event2 = caldav_sync::event::Event::new_all_day(
|
|
"Test Holiday".to_string(),
|
|
chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(),
|
|
);
|
|
|
|
// Test event serialization
|
|
let ical1 = event1.to_ical()?;
|
|
let ical2 = event2.to_ical()?;
|
|
|
|
assert!(!ical1.is_empty());
|
|
assert!(!ical2.is_empty());
|
|
assert!(ical1.contains("SUMMARY:Test Meeting"));
|
|
assert!(ical2.contains("SUMMARY:Test Holiday"));
|
|
|
|
// Test filtering
|
|
let filter = caldav_sync::calendar_filter::FilterBuilder::new()
|
|
.keywords(vec!["test".to_string()])
|
|
.build();
|
|
|
|
assert!(filter.matches_event(&event1));
|
|
assert!(filter.matches_event(&event2));
|
|
|
|
Ok(())
|
|
}
|
|
}
|