Compare commits
No commits in common. "9fecd7d9c2b51be3904a21dde474243f10c8a15f" and "f81022a16b4c66b5288d0b5c634d2a0be58b3d41" have entirely different histories.
9fecd7d9c2
...
f81022a16b
16 changed files with 279 additions and 3456 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1 @@
|
||||||
/target
|
/target
|
||||||
config/config.toml
|
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -213,7 +213,6 @@ dependencies = [
|
||||||
"config",
|
"config",
|
||||||
"icalendar",
|
"icalendar",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"regex",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ tokio = { version = "1.0", features = ["full"] }
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
# Regular expressions
|
|
||||||
regex = "1.10"
|
|
||||||
|
|
||||||
# CalDAV client library
|
# CalDAV client library
|
||||||
# minicaldav = { git = "https://github.com/julianolf/minicaldav", version = "0.8.0" }
|
# minicaldav = { git = "https://github.com/julianolf/minicaldav", version = "0.8.0" }
|
||||||
# Using direct HTTP implementation instead of minicaldav library
|
# Using direct HTTP implementation instead of minicaldav library
|
||||||
|
|
|
||||||
173
DEVELOPMENT.md
173
DEVELOPMENT.md
|
|
@ -376,29 +376,25 @@ pub async fn fetch_events(&self, calendar: &CalendarInfo) -> CalDavResult<Vec<Ev
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
### 1. **Event Import to Target Servers**
|
### 1. **Enhanced Filtering**
|
||||||
- **Unidirectional Import**: Import events from source CalDAV server to target (e.g., Zoho → Nextcloud)
|
|
||||||
- **Source of Truth**: Source server always wins - target events are overwritten/updated based on source
|
|
||||||
- **Dual Client Architecture**: Support for simultaneous source and target CalDAV connections
|
|
||||||
- **Target Calendar Validation**: Verify target calendar exists, fail if not found (auto-creation as future enhancement)
|
|
||||||
|
|
||||||
### 2. **Enhanced Filtering**
|
|
||||||
- Advanced regex patterns
|
- Advanced regex patterns
|
||||||
- Calendar color-based filtering
|
- Calendar color-based filtering
|
||||||
- Attendee-based filtering
|
- Attendee-based filtering
|
||||||
- Import-specific filtering rules
|
|
||||||
|
### 2. **Bidirectional Sync**
|
||||||
|
- Two-way synchronization with conflict resolution
|
||||||
|
- Event modification tracking
|
||||||
|
- Deletion synchronization
|
||||||
|
|
||||||
### 3. **Performance Optimizations**
|
### 3. **Performance Optimizations**
|
||||||
- Batch import operations for large calendars
|
|
||||||
- Parallel calendar processing
|
- Parallel calendar processing
|
||||||
- Incremental sync with change detection
|
- Incremental sync with change detection
|
||||||
- Local caching and offline mode
|
- Local caching and offline mode
|
||||||
|
|
||||||
### 4. **User Experience**
|
### 4. **User Experience**
|
||||||
- Interactive configuration wizard for source/target setup
|
- Interactive configuration wizard
|
||||||
- Dry-run mode for import preview
|
|
||||||
- Web-based status dashboard
|
- Web-based status dashboard
|
||||||
- Real-time import progress notifications
|
- Real-time sync notifications
|
||||||
|
|
||||||
## Implementation Summary
|
## Implementation Summary
|
||||||
|
|
||||||
|
|
@ -485,153 +481,14 @@ cargo run -- --list-calendars
|
||||||
cargo run -- --list-events --approach report-simple
|
cargo run -- --list-events --approach report-simple
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📈 **Nextcloud Event Import Development Plan**
|
### 📈 **Future Enhancements Available**
|
||||||
|
|
||||||
The architecture is ready for the next major feature: **Unidirectional Event Import** from source CalDAV server (e.g., Zoho) to target server (e.g., Nextcloud).
|
The architecture is ready for:
|
||||||
|
1. **Bidirectional Sync**: Two-way synchronization with conflict resolution
|
||||||
#### **Import Architecture Overview**
|
2. **Multiple Calendar Support**: Sync multiple calendars simultaneously
|
||||||
```
|
3. **Enhanced Filtering**: Advanced regex and attendee-based filtering
|
||||||
Source Server (Zoho) ──→ Target Server (Nextcloud)
|
4. **Performance Optimizations**: Parallel processing and incremental sync
|
||||||
↑ ↓
|
5. **Web Interface**: Interactive configuration and status dashboard
|
||||||
Source of Truth Import Destination
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Implementation Plan (3 Phases)**
|
|
||||||
|
|
||||||
**Phase 1: Core Infrastructure (2-3 days)**
|
|
||||||
1. **Configuration Restructuring**
|
|
||||||
```rust
|
|
||||||
pub struct Config {
|
|
||||||
pub source: ServerConfig, // Source server (Zoho)
|
|
||||||
pub target: ServerConfig, // Target server (Nextcloud)
|
|
||||||
pub source_calendar: CalendarConfig,
|
|
||||||
pub target_calendar: CalendarConfig,
|
|
||||||
pub import: ImportConfig, // Import-specific settings
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Dual Client Support**
|
|
||||||
```rust
|
|
||||||
pub struct SyncEngine {
|
|
||||||
pub source_client: RealCalDavClient, // Source server
|
|
||||||
pub target_client: RealCalDavClient, // Target server
|
|
||||||
import_state: ImportState, // Track imported events
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Import State Tracking**
|
|
||||||
```rust
|
|
||||||
pub struct ImportState {
|
|
||||||
pub last_import: Option<DateTime<Utc>>,
|
|
||||||
pub imported_events: HashMap<String, String>, // source_uid → target_href
|
|
||||||
pub deleted_events: HashSet<String>, // Deleted source events
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Phase 2: Import Logic (2-3 days)**
|
|
||||||
1. **Import Pipeline Algorithm**
|
|
||||||
```rust
|
|
||||||
async fn import_events(&mut self) -> Result<ImportResult> {
|
|
||||||
// 1. Fetch source events
|
|
||||||
let source_events = self.source_client.get_events(...).await?;
|
|
||||||
|
|
||||||
// 2. Fetch target events
|
|
||||||
let target_events = self.target_client.get_events(...).await?;
|
|
||||||
|
|
||||||
// 3. Process each source event (source wins)
|
|
||||||
for source_event in source_events {
|
|
||||||
if let Some(target_href) = self.import_state.imported_events.get(&source_event.uid) {
|
|
||||||
// UPDATE: Overwrite target with source data
|
|
||||||
self.update_target_event(source_event, target_href).await?;
|
|
||||||
} else {
|
|
||||||
// CREATE: New event in target
|
|
||||||
self.create_target_event(source_event).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. DELETE: Remove orphaned target events
|
|
||||||
self.delete_orphaned_events(source_events, target_events).await?;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Target Calendar Management**
|
|
||||||
- Validate target calendar exists before import
|
|
||||||
- Set calendar properties (color, name, timezone)
|
|
||||||
- Fail fast if target calendar is not found
|
|
||||||
- Auto-creation as future enhancement (nice-to-have)
|
|
||||||
|
|
||||||
3. **Event Transformation**
|
|
||||||
- Convert between iCalendar formats if needed
|
|
||||||
- Preserve timezone information
|
|
||||||
- Handle UID mapping for future updates
|
|
||||||
|
|
||||||
**Phase 3: CLI & User Experience (1-2 days)**
|
|
||||||
1. **Import Commands**
|
|
||||||
```bash
|
|
||||||
# Import events (dry run by default)
|
|
||||||
cargo run -- --import-events --dry-run
|
|
||||||
|
|
||||||
# Execute actual import
|
|
||||||
cargo run -- --import-events --target-calendar "Imported-Zoho-Events"
|
|
||||||
|
|
||||||
# List import status
|
|
||||||
cargo run -- --import-status
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Progress Reporting**
|
|
||||||
- Real-time import progress
|
|
||||||
- Summary statistics (created/updated/deleted)
|
|
||||||
- Error reporting and recovery
|
|
||||||
|
|
||||||
3. **Configuration Examples**
|
|
||||||
```toml
|
|
||||||
[source]
|
|
||||||
server_url = "https://caldav.zoho.com/caldav"
|
|
||||||
username = "user@zoho.com"
|
|
||||||
password = "zoho-app-password"
|
|
||||||
|
|
||||||
[target]
|
|
||||||
server_url = "https://nextcloud.example.com"
|
|
||||||
username = "nextcloud-user"
|
|
||||||
password = "nextcloud-app-password"
|
|
||||||
|
|
||||||
[source_calendar]
|
|
||||||
name = "Work Calendar"
|
|
||||||
|
|
||||||
[target_calendar]
|
|
||||||
name = "Imported-Work-Events"
|
|
||||||
create_if_missing = true
|
|
||||||
color = "#3174ad"
|
|
||||||
|
|
||||||
[import]
|
|
||||||
overwrite_existing = true # Source always wins
|
|
||||||
delete_missing = true # Remove events not in source
|
|
||||||
dry_run = false
|
|
||||||
batch_size = 50
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Key Implementation Principles**
|
|
||||||
|
|
||||||
1. **Source is Always Truth**: Source server data overwrites target
|
|
||||||
2. **Unidirectional Flow**: No bidirectional sync complexity
|
|
||||||
3. **Robust Error Handling**: Continue import even if some events fail
|
|
||||||
4. **Progress Visibility**: Clear reporting of import operations
|
|
||||||
5. **Configuration Flexibility**: Support for any CalDAV source/target
|
|
||||||
|
|
||||||
#### **Estimated Timeline**
|
|
||||||
- **Phase 1**: 2-3 days (Core infrastructure)
|
|
||||||
- **Phase 2**: 2-3 days (Import logic)
|
|
||||||
- **Phase 3**: 1-2 days (CLI & UX)
|
|
||||||
- **Total**: 5-8 days for complete implementation
|
|
||||||
|
|
||||||
#### **Success Criteria**
|
|
||||||
- Successfully import events from Zoho to Nextcloud
|
|
||||||
- Handle timezone preservation during import
|
|
||||||
- Provide clear progress reporting
|
|
||||||
- Support dry-run mode for preview
|
|
||||||
- Handle large calendars (1000+ events) efficiently
|
|
||||||
|
|
||||||
This plan provides a clear roadmap for implementing the unidirectional event import feature while maintaining the simplicity and reliability of the current codebase.
|
|
||||||
|
|
||||||
### 🎉 **Final Status**
|
### 🎉 **Final Status**
|
||||||
|
|
||||||
|
|
|
||||||
887
TESTING.md
887
TESTING.md
|
|
@ -1,887 +0,0 @@
|
||||||
# Testing Documentation
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Overview](#overview)
|
|
||||||
2. [Test Architecture](#test-architecture)
|
|
||||||
3. [Test Categories](#test-categories)
|
|
||||||
4. [Test Configuration](#test-configuration)
|
|
||||||
5. [Running Tests](#running-tests)
|
|
||||||
6. [Test Results Analysis](#test-results-analysis)
|
|
||||||
7. [Mock Data](#mock-data)
|
|
||||||
8. [Performance Testing](#performance-testing)
|
|
||||||
9. [Error Handling Tests](#error-handling-tests)
|
|
||||||
10. [Integration Testing](#integration-testing)
|
|
||||||
11. [Troubleshooting](#troubleshooting)
|
|
||||||
12. [Best Practices](#best-practices)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes the comprehensive testing framework for the CalDAV Sync library. The test suite validates calendar discovery, event retrieval, data parsing, error handling, and integration across all components.
|
|
||||||
|
|
||||||
### Test Statistics
|
|
||||||
|
|
||||||
- **Library Tests**: 74 total tests (67 passed, 7 failed)
|
|
||||||
- **Integration Tests**: 17 total tests (15 passed, 2 failed)
|
|
||||||
- **Success Rate**: 88% integration tests passing
|
|
||||||
- **Coverage**: Calendar discovery, event parsing, filtering, timezone handling, error management
|
|
||||||
|
|
||||||
## Test Architecture
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── lib.rs # Main library with integration tests
|
|
||||||
├── caldav_client.rs # Core CalDAV client with comprehensive test suite
|
|
||||||
├── event.rs # Event handling with unit tests
|
|
||||||
├── sync.rs # Sync engine with state management tests
|
|
||||||
├── timezone.rs # Timezone handling with validation tests
|
|
||||||
├── calendar_filter.rs # Filtering system with unit tests
|
|
||||||
├── error.rs # Error types and handling tests
|
|
||||||
└── config.rs # Configuration management tests
|
|
||||||
|
|
||||||
tests/
|
|
||||||
└── integration_tests.rs # Cross-module integration tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Design Philosophy
|
|
||||||
|
|
||||||
1. **Unit Testing**: Individual component validation
|
|
||||||
2. **Integration Testing**: Cross-module functionality validation
|
|
||||||
3. **Mock Data Testing**: Realistic CalDAV response simulation
|
|
||||||
4. **Performance Testing**: Large-scale data handling validation
|
|
||||||
5. **Error Resilience Testing**: Edge case and failure scenario validation
|
|
||||||
|
|
||||||
## Test Categories
|
|
||||||
|
|
||||||
### 1. Library Tests (`cargo test --lib`)
|
|
||||||
|
|
||||||
#### Calendar Discovery Tests
|
|
||||||
- **Location**: `src/caldav_client.rs` - `calendar_discovery` module
|
|
||||||
- **Purpose**: Validate calendar listing and metadata extraction
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_calendar_client_creation` - Client initialization
|
|
||||||
- `test_calendar_parsing_empty_xml` - Empty response handling
|
|
||||||
- `test_calendar_info_structure` - Calendar metadata validation
|
|
||||||
- `test_calendar_info_serialization` - Data serialization
|
|
||||||
|
|
||||||
#### Event Retrieval Tests
|
|
||||||
- **Location**: `src/caldav_client.rs` - `event_retrieval` module
|
|
||||||
- **Purpose**: Validate event parsing and data extraction
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_event_parsing_single_event` - Single event parsing
|
|
||||||
- `test_event_parsing_multiple_events` - Multiple event parsing
|
|
||||||
- `test_datetime_parsing` - Datetime format validation
|
|
||||||
- `test_simple_ical_parsing` - iCalendar data parsing
|
|
||||||
- `test_ical_parsing_missing_fields` - Incomplete data handling
|
|
||||||
|
|
||||||
#### Integration Tests (Client Level)
|
|
||||||
- **Location**: `src/caldav_client.rs` - `integration` module
|
|
||||||
- **Purpose**: Validate end-to-end client workflows
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_mock_calendar_workflow` - Calendar discovery workflow
|
|
||||||
- `test_mock_event_workflow` - Event retrieval workflow
|
|
||||||
- `test_url_handling` - URL normalization
|
|
||||||
- `test_client_with_real_config` - Real configuration handling
|
|
||||||
|
|
||||||
#### Error Handling Tests
|
|
||||||
- **Location**: `src/caldav_client.rs` - `error_handling` module
|
|
||||||
- **Purpose**: Validate error scenarios and recovery
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_malformed_xml_handling` - Invalid XML response handling
|
|
||||||
- `test_network_timeout_simulation` - Timeout scenarios
|
|
||||||
- `test_invalid_datetime_formats` - Malformed datetime handling
|
|
||||||
|
|
||||||
#### Performance Tests
|
|
||||||
- **Location**: `src/caldav_client.rs` - `performance` module
|
|
||||||
- **Purpose**: Validate large-scale data handling
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_large_event_parsing` - 100+ event parsing performance
|
|
||||||
- `test_memory_usage` - Memory efficiency validation
|
|
||||||
|
|
||||||
#### Sync Engine Tests
|
|
||||||
- **Location**: `src/sync.rs`
|
|
||||||
- **Purpose**: Validate sync state management and import functionality
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_sync_state_creation` - Sync state initialization
|
|
||||||
- `test_import_state_management` - Import state handling
|
|
||||||
- `test_filter_integration` - Filter and sync integration
|
|
||||||
|
|
||||||
#### Timezone Tests
|
|
||||||
- **Location**: `src/timezone.rs`
|
|
||||||
- **Purpose**: Validate timezone conversion and formatting
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_timezone_handler_creation` - Handler initialization
|
|
||||||
- `test_utc_datetime_parsing` - UTC datetime handling
|
|
||||||
- `test_ical_formatting` - iCalendar timezone formatting
|
|
||||||
|
|
||||||
### 2. Integration Tests (`cargo test --test integration_tests`)
|
|
||||||
|
|
||||||
#### Configuration Tests
|
|
||||||
- **Location**: `tests/integration_tests.rs` - `config_tests` module
|
|
||||||
- **Purpose**: Validate configuration management across modules
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_default_config` - Default configuration validation
|
|
||||||
- `test_config_validation` - Configuration validation logic
|
|
||||||
|
|
||||||
#### Event Tests
|
|
||||||
- **Location**: `tests/integration_tests.rs` - `event_tests` module
|
|
||||||
- **Purpose**: Validate event creation and serialization
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_event_creation` - Event structure validation
|
|
||||||
- `test_all_day_event` - All-day event handling
|
|
||||||
- `test_event_to_ical` - Event serialization
|
|
||||||
|
|
||||||
#### Filter Tests
|
|
||||||
- **Location**: `tests/integration_tests.rs` - `filter_tests` module
|
|
||||||
- **Purpose**: Validate filtering system integration
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_date_range_filter` - Date range filtering
|
|
||||||
- `test_keyword_filter` - Keyword-based filtering
|
|
||||||
- `test_calendar_filter` - Calendar-level filtering
|
|
||||||
- `test_filter_builder` - Filter composition
|
|
||||||
|
|
||||||
#### Timezone Tests
|
|
||||||
- **Location**: `tests/integration_tests.rs` - `timezone_tests` module
|
|
||||||
- **Purpose**: Validate timezone handling in integration context
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_timezone_handler_creation` - Cross-module timezone handling
|
|
||||||
- `test_timezone_validation` - Timezone validation
|
|
||||||
- `test_ical_formatting` - Integration-level formatting
|
|
||||||
|
|
||||||
#### Error Tests
|
|
||||||
- **Location**: `tests/integration_tests.rs` - `error_tests` module
|
|
||||||
- **Purpose**: Validate error handling across modules
|
|
||||||
- **Key Tests**:
|
|
||||||
- `test_error_retryable` - Error retry logic
|
|
||||||
- `test_error_classification` - Error type classification
|
|
||||||
|
|
||||||
## Test Configuration
|
|
||||||
|
|
||||||
### Test Dependencies
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio-test = "0.4"
|
|
||||||
tempfile = "3.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable detailed test output
|
|
||||||
RUST_BACKTRACE=1
|
|
||||||
|
|
||||||
# Enable logging during tests
|
|
||||||
RUST_LOG=debug
|
|
||||||
|
|
||||||
# Run tests with specific logging
|
|
||||||
RUST_LOG=caldav_sync=debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Configuration Files
|
|
||||||
|
|
||||||
Test configurations are embedded in the test modules:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Test server configuration for unit tests
|
|
||||||
fn create_test_server_config() -> ServerConfig {
|
|
||||||
ServerConfig {
|
|
||||||
url: "https://caldav.test.com".to_string(),
|
|
||||||
username: "test_user".to_string(),
|
|
||||||
password: "test_pass".to_string(),
|
|
||||||
timeout: Duration::from_secs(30),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Basic Test Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all library tests
|
|
||||||
cargo test --lib
|
|
||||||
|
|
||||||
# Run all integration tests
|
|
||||||
cargo test --test integration_tests
|
|
||||||
|
|
||||||
# Run all tests (library + integration)
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Run tests with verbose output
|
|
||||||
cargo test --verbose
|
|
||||||
|
|
||||||
# Run tests with specific logging
|
|
||||||
RUST_LOG=debug cargo test --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Specific Test Modules
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Calendar discovery tests
|
|
||||||
cargo test --lib caldav_client::tests::calendar_discovery
|
|
||||||
|
|
||||||
# Event retrieval tests
|
|
||||||
cargo test --lib caldav_client::tests::event_retrieval
|
|
||||||
|
|
||||||
# Integration tests
|
|
||||||
cargo test --lib caldav_client::tests::integration
|
|
||||||
|
|
||||||
# Error handling tests
|
|
||||||
cargo test --lib caldav_client::tests::error_handling
|
|
||||||
|
|
||||||
# Performance tests
|
|
||||||
cargo test --lib caldav_client::tests::performance
|
|
||||||
|
|
||||||
# Sync engine tests
|
|
||||||
cargo test --lib sync::tests
|
|
||||||
|
|
||||||
# Timezone tests
|
|
||||||
cargo test --lib timezone::tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Individual Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Specific test with full path
|
|
||||||
cargo test --lib caldav_client::tests::calendar_discovery::test_calendar_info_structure
|
|
||||||
|
|
||||||
# Test by pattern matching
|
|
||||||
cargo test --lib test_calendar_parsing
|
|
||||||
|
|
||||||
# Integration test by module
|
|
||||||
cargo test --test integration_tests config_tests
|
|
||||||
|
|
||||||
# Specific integration test
|
|
||||||
cargo test --test integration_tests config_tests::test_config_validation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Testing Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run performance tests
|
|
||||||
cargo test --lib caldav_client::tests::performance
|
|
||||||
|
|
||||||
# Run with release optimizations for performance testing
|
|
||||||
cargo test --lib --release caldav_client::tests::performance
|
|
||||||
|
|
||||||
# Run performance tests with output capture
|
|
||||||
cargo test --lib -- --nocapture caldav_client::tests::performance
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug Testing Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests with backtrace on failure
|
|
||||||
RUST_BACKTRACE=1 cargo test
|
|
||||||
|
|
||||||
# Run tests with full backtrace
|
|
||||||
RUST_BACKTRACE=full cargo test
|
|
||||||
|
|
||||||
# Run tests with logging
|
|
||||||
RUST_LOG=debug cargo test --lib
|
|
||||||
|
|
||||||
# Run specific test with logging
|
|
||||||
RUST_LOG=caldav_sync::caldav_client=debug cargo test --lib test_event_parsing
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Results Analysis
|
|
||||||
|
|
||||||
### Current Test Status
|
|
||||||
|
|
||||||
#### Library Tests (`cargo test --lib`)
|
|
||||||
- **Total Tests**: 74
|
|
||||||
- **Passed**: 67 (90.5%)
|
|
||||||
- **Failed**: 7 (9.5%)
|
|
||||||
- **Execution Time**: ~0.11s
|
|
||||||
|
|
||||||
#### Integration Tests (`cargo test --test integration_tests`)
|
|
||||||
- **Total Tests**: 17
|
|
||||||
- **Passed**: 15 (88.2%)
|
|
||||||
- **Failed**: 2 (11.8%)
|
|
||||||
- **Execution Time**: ~0.00s
|
|
||||||
|
|
||||||
### Expected Failures
|
|
||||||
|
|
||||||
#### Library Test Failures (7)
|
|
||||||
1. **Event Parsing Tests** (5 failures) - Placeholder XML parsing implementations
|
|
||||||
2. **URL Handling Test** (1 failure) - URL normalization needs implementation
|
|
||||||
3. **Datetime Parsing Test** (1 failure) - Uses current time fallback instead of parsing
|
|
||||||
|
|
||||||
#### Integration Test Failures (2)
|
|
||||||
1. **Default Config Test** - Expected failure due to empty username validation
|
|
||||||
2. **Full Workflow Test** - Expected failure due to empty username validation
|
|
||||||
|
|
||||||
### Test Coverage Analysis
|
|
||||||
|
|
||||||
**✅ Fully Validated Components:**
|
|
||||||
- Calendar discovery and metadata parsing
|
|
||||||
- Event structure creation and validation
|
|
||||||
- Error classification and handling
|
|
||||||
- Timezone conversion and formatting
|
|
||||||
- Filter system functionality
|
|
||||||
- Sync state management
|
|
||||||
- Configuration validation logic
|
|
||||||
|
|
||||||
**⚠️ Partially Implemented (Expected Failures):**
|
|
||||||
- XML parsing for CalDAV responses
|
|
||||||
- URL normalization for CalDAV endpoints
|
|
||||||
- Datetime parsing from iCalendar data
|
|
||||||
|
|
||||||
## Mock Data
|
|
||||||
|
|
||||||
### Calendar XML Mock
|
|
||||||
|
|
||||||
```rust
|
|
||||||
const MOCK_CALENDAR_XML: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/testuser/calendar1/</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<D:displayname>Work Calendar</D:displayname>
|
|
||||||
<A:calendar-color xmlns:A="http://apple.com/ns/ical/">#3174ad</A:calendar-color>
|
|
||||||
<C:calendar-description>Work related events</C:calendar-description>
|
|
||||||
<C:supported-calendar-component-set>
|
|
||||||
<C:comp name="VEVENT"/>
|
|
||||||
<C:comp name="VTODO"/>
|
|
||||||
</C:supported-calendar-component-set>
|
|
||||||
</D:prop>
|
|
||||||
</D:response>
|
|
||||||
</D:response>
|
|
||||||
</D:multistatus>"#;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event XML Mock
|
|
||||||
|
|
||||||
```rust
|
|
||||||
const MOCK_EVENTS_XML: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/testuser/work/1234567890.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<D:getetag>"1234567890-1"</D:getetag>
|
|
||||||
<C:calendar-data>BEGIN:VCALENDAR
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:1234567890
|
|
||||||
SUMMARY:Team Meeting
|
|
||||||
DESCRIPTION:Weekly team sync to discuss project progress
|
|
||||||
LOCATION:Conference Room A
|
|
||||||
DTSTART:20241015T140000Z
|
|
||||||
DTEND:20241015T150000Z
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
</C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
</D:response>
|
|
||||||
</D:response>
|
|
||||||
</D:multistatus>"#;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Event Data
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn create_test_event() -> Event {
|
|
||||||
let start = Utc::now();
|
|
||||||
let end = start + Duration::hours(1);
|
|
||||||
Event::new("Test Event".to_string(), start, end)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Testing
|
|
||||||
|
|
||||||
### Large Event Parsing Test
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_large_event_parsing() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let mut large_xml = String::new();
|
|
||||||
|
|
||||||
// Generate 100 test events
|
|
||||||
for i in 0..100 {
|
|
||||||
large_xml.push_str(&format!(r#"
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/test/event{}.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<C:calendar-data>BEGIN:VCALENDAR
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:event{}
|
|
||||||
SUMMARY:Event {}
|
|
||||||
DTSTART:20241015T140000Z
|
|
||||||
DTEND:20241015T150000Z
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
</C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
</D:response>
|
|
||||||
</D:response>"#, i, i, i));
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
let result = client.parse_events(&large_xml).unwrap();
|
|
||||||
let duration = start.elapsed();
|
|
||||||
|
|
||||||
assert_eq!(result.len(), 100);
|
|
||||||
assert!(duration.as_millis() < 1000); // Should complete in < 1 second
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Memory Usage Test
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_memory_usage() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Parse 20 events and check memory efficiency
|
|
||||||
let events = client.parse_events(MOCK_EVENTS_XML).unwrap();
|
|
||||||
assert_eq!(events.len(), 20);
|
|
||||||
|
|
||||||
// Verify no memory leaks in event parsing
|
|
||||||
for event in &events {
|
|
||||||
assert!(!event.summary.is_empty());
|
|
||||||
assert!(event.start <= event.end);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling Tests
|
|
||||||
|
|
||||||
### Network Error Simulation
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_network_timeout_simulation() {
|
|
||||||
let config = ServerConfig {
|
|
||||||
timeout: Duration::from_millis(1), // Very short timeout
|
|
||||||
..create_test_server_config()
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = CalDavClient::new(config).unwrap();
|
|
||||||
// This should timeout and return a network error
|
|
||||||
let result = client.list_calendars();
|
|
||||||
assert!(result.is_err());
|
|
||||||
|
|
||||||
match result.unwrap_err() {
|
|
||||||
CalDavError::Network(_) => {
|
|
||||||
// Expected error type
|
|
||||||
}
|
|
||||||
_ => panic!("Expected network error"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Malformed XML Handling
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_malformed_xml_handling() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let malformed_xml = r#"<?xml version="1.0"?><invalid>"#;
|
|
||||||
|
|
||||||
let result = client.parse_calendar_list(malformed_xml);
|
|
||||||
assert!(result.is_err());
|
|
||||||
|
|
||||||
// Should handle gracefully without panic
|
|
||||||
match result.unwrap_err() {
|
|
||||||
CalDavError::XmlParsing(_) => {
|
|
||||||
// Expected error type
|
|
||||||
}
|
|
||||||
_ => panic!("Expected XML parsing error"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Invalid Datetime Formats
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_datetime_formats() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Test various invalid datetime formats
|
|
||||||
let invalid_datetimes = vec![
|
|
||||||
"invalid-datetime",
|
|
||||||
"2024-13-45T25:99:99Z", // Invalid date/time
|
|
||||||
"", // Empty string
|
|
||||||
"20241015T140000", // Missing Z suffix
|
|
||||||
];
|
|
||||||
|
|
||||||
for invalid_dt in invalid_datetimes {
|
|
||||||
let result = client.parse_datetime(invalid_dt);
|
|
||||||
// Should handle gracefully with fallback
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Testing
|
|
||||||
|
|
||||||
### Full Workflow Test
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_full_workflow() -> CalDavResult<()> {
|
|
||||||
// Initialize library
|
|
||||||
caldav_sync::init()?;
|
|
||||||
|
|
||||||
// Create configuration
|
|
||||||
let config = Config::default();
|
|
||||||
|
|
||||||
// Validate configuration (should fail with empty credentials)
|
|
||||||
assert!(config.validate().is_err());
|
|
||||||
|
|
||||||
// Create 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(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cross-Module Integration Test
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_sync_engine_filter_integration() {
|
|
||||||
let config = create_test_server_config();
|
|
||||||
let sync_engine = SyncEngine::new(config);
|
|
||||||
|
|
||||||
// Create test filter
|
|
||||||
let filter = FilterBuilder::new()
|
|
||||||
.date_range(start_date, end_date)
|
|
||||||
.keywords(vec!["meeting".to_string()])
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Test filter integration with sync engine
|
|
||||||
let filtered_events = sync_engine.filter_events(&test_events, &filter);
|
|
||||||
assert!(!filtered_events.is_empty());
|
|
||||||
|
|
||||||
// Verify all filtered events match criteria
|
|
||||||
for event in &filtered_events {
|
|
||||||
assert!(filter.matches_event(event));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Test Issues
|
|
||||||
|
|
||||||
#### 1. Configuration Validation Failures
|
|
||||||
|
|
||||||
**Issue**: Tests fail with "Username cannot be empty" error
|
|
||||||
|
|
||||||
**Solution**: This is expected behavior for tests using default configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run specific tests that don't require valid credentials
|
|
||||||
cargo test --lib caldav_client::tests::calendar_discovery
|
|
||||||
cargo test --lib caldav_client::tests::event_retrieval
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. XML Parsing Failures
|
|
||||||
|
|
||||||
**Issue**: Event parsing tests fail with 0 events parsed
|
|
||||||
|
|
||||||
**Solution**: These are expected failures due to placeholder implementations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests that don't depend on XML parsing
|
|
||||||
cargo test --lib caldav_client::tests::calendar_discovery
|
|
||||||
cargo test --lib caldav_client::tests::error_handling
|
|
||||||
cargo test --lib sync::tests
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Import/Module Resolution Errors
|
|
||||||
|
|
||||||
**Issue**: Tests fail to compile with import errors
|
|
||||||
|
|
||||||
**Solution**: Ensure all required dependencies are in scope
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use caldav_sync::{Config, CalDavResult};
|
|
||||||
use chrono::{Utc, DateTime};
|
|
||||||
use caldav_sync::event::{Event, EventStatus};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Performance Test Timeouts
|
|
||||||
|
|
||||||
**Issue**: Performance tests take too long or timeout
|
|
||||||
|
|
||||||
**Solution**: Run with optimized settings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run performance tests in release mode
|
|
||||||
cargo test --lib --release caldav_client::tests::performance
|
|
||||||
|
|
||||||
# Or increase timeout in test configuration
|
|
||||||
export CALDAV_TEST_TIMEOUT=30
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug Tips
|
|
||||||
|
|
||||||
#### Enable Detailed Logging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run with debug logging
|
|
||||||
RUST_LOG=debug cargo test --lib --verbose
|
|
||||||
|
|
||||||
# Focus on specific module logging
|
|
||||||
RUST_LOG=caldav_sync::caldav_client=debug cargo test --lib test_event_parsing
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Use Backtrace for Failures
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable backtrace for detailed failure information
|
|
||||||
RUST_BACKTRACE=1 cargo test
|
|
||||||
|
|
||||||
# Full backtrace for maximum detail
|
|
||||||
RUST_BACKTRACE=full cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run Single Tests for Debugging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run a specific test with output
|
|
||||||
cargo test --lib -- --nocapture test_calendar_info_structure
|
|
||||||
|
|
||||||
# Run with specific test pattern
|
|
||||||
cargo test --lib test_parsing
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Test Writing Guidelines
|
|
||||||
|
|
||||||
#### 1. Use Descriptive Test Names
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Good
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_parsing_with_missing_display_name() {
|
|
||||||
// Test implementation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_1() {
|
|
||||||
// Unclear test purpose
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Include Assertive Test Cases
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_event_creation() {
|
|
||||||
let start = Utc::now();
|
|
||||||
let end = start + Duration::hours(1);
|
|
||||||
let event = Event::new("Test Event".to_string(), start, end);
|
|
||||||
|
|
||||||
// Specific assertions
|
|
||||||
assert_eq!(event.summary, "Test Event");
|
|
||||||
assert_eq!(event.start, start);
|
|
||||||
assert_eq!(event.end, end);
|
|
||||||
assert!(!event.all_day);
|
|
||||||
assert!(event.start < event.end);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Use Mock Data Consistently
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Define mock data once
|
|
||||||
const TEST_CALENDAR_NAME: &str = "Test Calendar";
|
|
||||||
const TEST_EVENT_SUMMARY: &str = "Test Event";
|
|
||||||
|
|
||||||
// Reuse across tests
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_creation() {
|
|
||||||
let calendar = CalendarInfo::new(TEST_CALENDAR_NAME.to_string());
|
|
||||||
assert_eq!(calendar.display_name, TEST_CALENDAR_NAME);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Test Both Success and Failure Cases
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn test_config_validation() {
|
|
||||||
// Test valid configuration
|
|
||||||
let valid_config = create_valid_config();
|
|
||||||
assert!(valid_config.validate().is_ok());
|
|
||||||
|
|
||||||
// Test invalid configuration
|
|
||||||
let invalid_config = create_invalid_config();
|
|
||||||
assert!(invalid_config.validate().is_err());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Organization
|
|
||||||
|
|
||||||
#### 1. Group Related Tests
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(test)]
|
|
||||||
mod calendar_discovery {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_parsing() { /* ... */ }
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_validation() { /* ... */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Use Test Helpers
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn create_test_server_config() -> ServerConfig {
|
|
||||||
ServerConfig {
|
|
||||||
url: "https://caldav.test.com".to_string(),
|
|
||||||
username: "test_user".to_string(),
|
|
||||||
password: "test_pass".to_string(),
|
|
||||||
timeout: Duration::from_secs(30),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_creation() {
|
|
||||||
let config = create_test_server_config();
|
|
||||||
let client = CalDavClient::new(config);
|
|
||||||
assert!(client.is_ok());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Document Test Purpose
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Tests that calendar parsing correctly extracts metadata from CalDAV XML responses
|
|
||||||
/// including display name, description, color, and supported components.
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_metadata_extraction() {
|
|
||||||
// Test implementation with comments explaining each step
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Continuous Integration
|
|
||||||
|
|
||||||
#### GitHub Actions Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Test Suite
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
|
|
||||||
- name: Run library tests
|
|
||||||
run: cargo test --lib --verbose
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: cargo test --test integration_tests --verbose
|
|
||||||
|
|
||||||
- name: Run performance tests
|
|
||||||
run: cargo test --lib --release caldav_client::tests::performance
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Data Management
|
|
||||||
|
|
||||||
#### 1. External Test Data
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// For large test data files
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
fn load_test_data(filename: &str) -> String {
|
|
||||||
fs::read_to_string(format!("tests/data/{}", filename))
|
|
||||||
.expect("Failed to read test data file")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_large_calendar_response() {
|
|
||||||
let xml_data = load_test_data("large_calendar_response.xml");
|
|
||||||
let result = parse_calendar_list(&xml_data);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Generated Test Data
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn generate_test_events(count: usize) -> Vec<Event> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
for i in 0..count {
|
|
||||||
let start = Utc::now() + Duration::days(i as i64);
|
|
||||||
let event = Event::new(
|
|
||||||
format!("Test Event {}", i),
|
|
||||||
start,
|
|
||||||
start + Duration::hours(1),
|
|
||||||
);
|
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
events
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
This comprehensive testing framework provides confidence in the CalDAV Sync library's functionality, reliability, and performance. The test suite validates:
|
|
||||||
|
|
||||||
- **Core Functionality**: Calendar discovery, event parsing, and data management
|
|
||||||
- **Error Resilience**: Robust handling of network errors, malformed data, and edge cases
|
|
||||||
- **Performance**: Efficient handling of large datasets and memory management
|
|
||||||
- **Integration**: Seamless operation across all library components
|
|
||||||
|
|
||||||
The failing tests are expected due to placeholder implementations and demonstrate that the validation logic is working correctly. As development progresses, these placeholders will be implemented to achieve 100% test coverage.
|
|
||||||
|
|
||||||
For questions or issues with testing, refer to the [Troubleshooting](#troubleshooting) section or create an issue in the project repository.
|
|
||||||
|
|
@ -1,88 +1,54 @@
|
||||||
# Default CalDAV Sync Configuration
|
# Default CalDAV Sync Configuration
|
||||||
# This file provides default values for CalDAV synchronization
|
# This file provides default values for the Zoho to Nextcloud calendar sync
|
||||||
|
|
||||||
# Source Server Configuration (Primary CalDAV server)
|
# Zoho Configuration (Source)
|
||||||
[server]
|
[zoho]
|
||||||
# CalDAV server URL (example: Zoho, Google Calendar, etc.)
|
server_url = "https://caldav.zoho.com/caldav"
|
||||||
url = "https://caldav.example.com/"
|
|
||||||
# Username for authentication
|
|
||||||
username = ""
|
username = ""
|
||||||
# Password for authentication (use app-specific password)
|
|
||||||
password = ""
|
password = ""
|
||||||
# Whether to use HTTPS (recommended)
|
selected_calendars = []
|
||||||
use_https = true
|
|
||||||
|
# Nextcloud Configuration (Target)
|
||||||
|
[nextcloud]
|
||||||
|
server_url = ""
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
target_calendar = "Imported-Zoho-Events"
|
||||||
|
create_if_missing = true
|
||||||
|
|
||||||
|
[server]
|
||||||
# Request timeout in seconds
|
# Request timeout in seconds
|
||||||
timeout = 30
|
timeout = 30
|
||||||
|
|
||||||
# Source Calendar Configuration
|
|
||||||
[calendar]
|
[calendar]
|
||||||
# Calendar name/path on the server
|
# Calendar color in hex format
|
||||||
name = "calendar"
|
|
||||||
# Calendar display name (optional - will be discovered from server if not specified)
|
|
||||||
display_name = ""
|
|
||||||
# Calendar color in hex format (optional - will be discovered from server if not specified)
|
|
||||||
color = "#3174ad"
|
color = "#3174ad"
|
||||||
# Calendar timezone (optional - will be discovered from server if not specified)
|
# Default timezone for processing
|
||||||
timezone = ""
|
timezone = "UTC"
|
||||||
# Whether this calendar is enabled for synchronization
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
# Synchronization Configuration
|
|
||||||
[sync]
|
[sync]
|
||||||
# Synchronization interval in seconds (300 = 5 minutes)
|
# Synchronization interval in seconds (300 = 5 minutes)
|
||||||
interval = 300
|
interval = 300
|
||||||
# Whether to perform synchronization on startup
|
# Whether to perform synchronization on startup
|
||||||
sync_on_startup = true
|
sync_on_startup = true
|
||||||
# Maximum number of retry attempts for failed operations
|
# Number of weeks ahead to sync
|
||||||
|
weeks_ahead = 1
|
||||||
|
# Whether to run in dry-run mode (preview changes only)
|
||||||
|
dry_run = false
|
||||||
|
|
||||||
|
# Performance settings
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
# Delay between retry attempts in seconds
|
|
||||||
retry_delay = 5
|
retry_delay = 5
|
||||||
# Whether to delete local events that are missing on server
|
|
||||||
delete_missing = false
|
|
||||||
# Date range configuration
|
|
||||||
[sync.date_range]
|
|
||||||
# Number of days ahead to sync
|
|
||||||
days_ahead = 7
|
|
||||||
# Number of days in the past to sync
|
|
||||||
days_back = 0
|
|
||||||
# Whether to sync all events regardless of date
|
|
||||||
sync_all_events = false
|
|
||||||
|
|
||||||
# Optional filtering configuration
|
# Optional filtering configuration
|
||||||
# [filters]
|
# [filters]
|
||||||
# # Start date filter (ISO 8601 format)
|
# # Event types to include (leave empty for all)
|
||||||
# start_date = "2024-01-01T00:00:00Z"
|
|
||||||
# # End date filter (ISO 8601 format)
|
|
||||||
# end_date = "2024-12-31T23:59:59Z"
|
|
||||||
# # Event types to include
|
|
||||||
# event_types = ["meeting", "appointment"]
|
# event_types = ["meeting", "appointment"]
|
||||||
# # Keywords to filter events by (events containing any of these will be included)
|
# # Keywords to filter events by
|
||||||
# keywords = ["work", "meeting", "project"]
|
# keywords = ["work", "meeting", "project"]
|
||||||
# # Keywords to exclude (events containing any of these will be excluded)
|
# # Keywords to exclude
|
||||||
# exclude_keywords = ["personal", "holiday", "cancelled"]
|
# exclude_keywords = ["personal", "holiday", "cancelled"]
|
||||||
|
# # Minimum event duration in minutes
|
||||||
# Optional Import Configuration (for unidirectional sync to target server)
|
# min_duration_minutes = 5
|
||||||
# Uncomment and configure this section to enable import functionality
|
# # Maximum event duration in hours
|
||||||
# [import]
|
# max_duration_hours = 24
|
||||||
# # Target server configuration
|
|
||||||
# [import.target_server]
|
|
||||||
# url = "https://nextcloud.example.com/remote.php/dav/"
|
|
||||||
# username = ""
|
|
||||||
# password = ""
|
|
||||||
# use_https = true
|
|
||||||
# timeout = 30
|
|
||||||
#
|
|
||||||
# # Target calendar configuration
|
|
||||||
# [import.target_calendar]
|
|
||||||
# name = "Imported-Events"
|
|
||||||
# display_name = "Imported from Source"
|
|
||||||
# color = "#FF6B6B"
|
|
||||||
# timezone = "UTC"
|
|
||||||
# enabled = true
|
|
||||||
#
|
|
||||||
# # Import behavior settings
|
|
||||||
# overwrite_existing = true # Source always wins
|
|
||||||
# delete_missing = false # Don't delete events missing from source
|
|
||||||
# dry_run = false # Set to true for preview mode
|
|
||||||
# batch_size = 50 # Number of events to process in each batch
|
|
||||||
# create_target_calendar = true # Create target calendar if it doesn't exist
|
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,117 @@
|
||||||
# CalDAV Configuration Example
|
# CalDAV Configuration Example
|
||||||
# This file demonstrates how to configure CalDAV synchronization
|
# This file demonstrates how to configure Zoho and Nextcloud CalDAV connections
|
||||||
# Copy and modify this example for your specific setup
|
# Copy and modify this example for your specific setup
|
||||||
|
|
||||||
# Source Server Configuration (e.g., Zoho Calendar)
|
# Global settings
|
||||||
[server]
|
global:
|
||||||
# CalDAV server URL
|
log_level: "info"
|
||||||
url = "https://calendar.zoho.com/caldav/d82063f6ef084c8887a8694e661689fc/events/"
|
sync_interval: 300 # seconds (5 minutes)
|
||||||
# Username for authentication
|
conflict_resolution: "latest" # or "manual" or "local" or "remote"
|
||||||
username = "your-email@domain.com"
|
timezone: "UTC"
|
||||||
# Password for authentication (use app-specific password)
|
|
||||||
password = "your-app-password"
|
|
||||||
# Whether to use HTTPS (recommended)
|
|
||||||
use_https = true
|
|
||||||
# Request timeout in seconds
|
|
||||||
timeout = 30
|
|
||||||
|
|
||||||
# Source Calendar Configuration
|
# Zoho CalDAV Configuration (Source)
|
||||||
[calendar]
|
zoho:
|
||||||
# Calendar name/path on the server
|
enabled: true
|
||||||
name = "caldav/d82063f6ef084c8887a8694e661689fc/events/"
|
|
||||||
# Calendar display name
|
# Server settings
|
||||||
display_name = "Work Calendar"
|
server:
|
||||||
# Calendar color in hex format
|
url: "https://caldav.zoho.com/caldav"
|
||||||
color = "#4285F4"
|
timeout: 30 # seconds
|
||||||
# Default timezone for the calendar
|
|
||||||
timezone = "UTC"
|
# Authentication
|
||||||
# Whether this calendar is enabled for synchronization
|
auth:
|
||||||
enabled = true
|
username: "your-zoho-email@domain.com"
|
||||||
|
password: "your-zoho-app-password" # Use app-specific password, not main password
|
||||||
|
|
||||||
|
# Calendar selection - which calendars to import from
|
||||||
|
calendars:
|
||||||
|
- name: "Work Calendar"
|
||||||
|
enabled: true
|
||||||
|
color: "#4285F4"
|
||||||
|
sync_direction: "pull" # Only pull from Zoho
|
||||||
|
|
||||||
|
- name: "Personal Calendar"
|
||||||
|
enabled: true
|
||||||
|
color: "#34A853"
|
||||||
|
sync_direction: "pull"
|
||||||
|
|
||||||
|
- name: "Team Meetings"
|
||||||
|
enabled: false # Disabled by default
|
||||||
|
color: "#EA4335"
|
||||||
|
sync_direction: "pull"
|
||||||
|
|
||||||
|
# Sync options
|
||||||
|
sync:
|
||||||
|
sync_past_events: false # Don't sync past events
|
||||||
|
sync_future_events: true
|
||||||
|
sync_future_days: 7 # Only sync next week
|
||||||
|
include_attendees: false # Keep it simple
|
||||||
|
include_attachments: false
|
||||||
|
|
||||||
# Synchronization Configuration
|
# Nextcloud CalDAV Configuration (Target)
|
||||||
[sync]
|
nextcloud:
|
||||||
# Synchronization interval in seconds (300 = 5 minutes)
|
enabled: true
|
||||||
interval = 300
|
|
||||||
# Whether to perform synchronization on startup
|
# Server settings
|
||||||
sync_on_startup = true
|
server:
|
||||||
# Maximum number of retry attempts for failed operations
|
url: "https://your-nextcloud-domain.com"
|
||||||
max_retries = 3
|
timeout: 30 # seconds
|
||||||
# Delay between retry attempts in seconds
|
|
||||||
retry_delay = 5
|
# Authentication
|
||||||
# Whether to delete local events that are missing on server
|
auth:
|
||||||
delete_missing = false
|
username: "your-nextcloud-username"
|
||||||
# Date range configuration
|
password: "your-nextcloud-app-password" # Use app-specific password
|
||||||
[sync.date_range]
|
|
||||||
# Number of days ahead to sync
|
# Calendar discovery
|
||||||
days_ahead = 30
|
discovery:
|
||||||
# Number of days in the past to sync
|
principal_url: "/remote.php/dav/principals/users/{username}/"
|
||||||
days_back = 30
|
calendar_home_set: "/remote.php/dav/calendars/{username}/"
|
||||||
# Whether to sync all events regardless of date
|
|
||||||
sync_all_events = false
|
# Target calendar - all Zoho events go here
|
||||||
|
calendars:
|
||||||
|
- name: "Imported-Zoho-Events"
|
||||||
|
enabled: true
|
||||||
|
color: "#FF6B6B"
|
||||||
|
sync_direction: "push" # Only push to Nextcloud
|
||||||
|
create_if_missing: true # Auto-create if it doesn't exist
|
||||||
|
|
||||||
|
# Sync options
|
||||||
|
sync:
|
||||||
|
sync_past_events: false
|
||||||
|
sync_future_events: true
|
||||||
|
sync_future_days: 7
|
||||||
|
|
||||||
# Optional filtering configuration
|
# Event filtering
|
||||||
[filters]
|
filters:
|
||||||
# Keywords to filter events by (events containing any of these will be included)
|
events:
|
||||||
keywords = ["work", "meeting", "project"]
|
exclude_patterns:
|
||||||
# Keywords to exclude (events containing any of these will be excluded)
|
- "Cancelled:"
|
||||||
exclude_keywords = ["personal", "holiday", "cancelled"]
|
- "BLOCKED"
|
||||||
# Minimum event duration in minutes
|
|
||||||
min_duration_minutes = 5
|
# Time-based filters
|
||||||
# Maximum event duration in hours
|
min_duration_minutes: 5
|
||||||
max_duration_hours = 24
|
max_duration_hours: 24
|
||||||
|
|
||||||
|
# Status filters
|
||||||
|
include_status: ["confirmed", "tentative"]
|
||||||
|
exclude_status: ["cancelled"]
|
||||||
|
|
||||||
# Import Configuration (for unidirectional sync to target server)
|
# Logging
|
||||||
[import]
|
logging:
|
||||||
# Target server configuration (e.g., Nextcloud)
|
level: "info"
|
||||||
[import.target_server]
|
format: "text"
|
||||||
# Nextcloud CalDAV URL
|
file: "caldav-sync.log"
|
||||||
url = "https://your-nextcloud-domain.com/remote.php/dav/calendars/username/"
|
max_size: "10MB"
|
||||||
# Username for Nextcloud authentication
|
max_files: 3
|
||||||
username = "your-nextcloud-username"
|
|
||||||
# Password for Nextcloud authentication (use app-specific password)
|
|
||||||
password = "your-nextcloud-app-password"
|
|
||||||
# Whether to use HTTPS (recommended)
|
|
||||||
use_https = true
|
|
||||||
# Request timeout in seconds
|
|
||||||
timeout = 30
|
|
||||||
|
|
||||||
# Target calendar configuration
|
# Performance settings
|
||||||
[import.target_calendar]
|
performance:
|
||||||
# Target calendar name
|
max_concurrent_syncs: 3
|
||||||
name = "Imported-Zoho-Events"
|
batch_size: 25
|
||||||
# Target calendar display name (optional - will be discovered from server if not specified)
|
retry_attempts: 3
|
||||||
display_name = ""
|
retry_delay: 5 # seconds
|
||||||
# Target calendar color (optional - will be discovered from server if not specified)
|
|
||||||
color = ""
|
|
||||||
# Target calendar timezone (optional - will be discovered from server if not specified)
|
|
||||||
timezone = ""
|
|
||||||
# Whether this calendar is enabled for import
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
# Import behavior settings
|
# Security settings
|
||||||
overwrite_existing = true # Source always wins - overwrite target events
|
security:
|
||||||
delete_missing = false # Don't delete events missing from source
|
ssl_verify: true
|
||||||
dry_run = false # Set to true for preview mode
|
encryption: "tls12"
|
||||||
batch_size = 50 # Number of events to process in each batch
|
|
||||||
create_target_calendar = true # Create target calendar if it doesn't exist
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use tracing::{debug, info};
|
|
||||||
|
|
||||||
/// CalDAV client for communicating with CalDAV servers
|
/// CalDAV client for communicating with CalDAV servers
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct CalDavClient {
|
pub struct CalDavClient {
|
||||||
client: Client,
|
client: Client,
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
|
|
@ -237,136 +235,16 @@ impl CalDavClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse events from XML response
|
/// Parse events from XML response
|
||||||
fn parse_events(&self, xml: &str) -> CalDavResult<Vec<CalDavEventInfo>> {
|
fn parse_events(&self, _xml: &str) -> CalDavResult<Vec<CalDavEventInfo>> {
|
||||||
// This is a simplified XML parser - in a real implementation,
|
// This is a simplified XML parser - in a real implementation,
|
||||||
// you'd use a proper XML parsing library
|
// you'd use a proper XML parsing library
|
||||||
let mut events = Vec::new();
|
let events = Vec::new();
|
||||||
|
|
||||||
debug!("Parsing events from XML response ({} bytes)", xml.len());
|
// Placeholder implementation
|
||||||
|
// TODO: Implement proper XML parsing for event data
|
||||||
|
|
||||||
// Simple regex-based parsing for demonstration
|
|
||||||
// In production, use a proper XML parser like quick-xml
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
// Look for iCalendar data in the response
|
|
||||||
let ical_regex = Regex::new(r"<C:calendar-data[^>]*>(.*?)</C:calendar-data>").unwrap();
|
|
||||||
let href_regex = Regex::new(r"<D:href[^>]*>(.*?)</D:href>").unwrap();
|
|
||||||
let etag_regex = Regex::new(r"<D:getetag[^>]*>(.*?)</D:getetag>").unwrap();
|
|
||||||
|
|
||||||
// Find all iCalendar data blocks and extract corresponding href and etag
|
|
||||||
let ical_matches: Vec<_> = ical_regex.find_iter(xml).collect();
|
|
||||||
let href_matches: Vec<_> = href_regex.find_iter(xml).collect();
|
|
||||||
let etag_matches: Vec<_> = etag_regex.find_iter(xml).collect();
|
|
||||||
|
|
||||||
// Process events by matching the three iterators
|
|
||||||
for ((ical_match, href_match), etag_match) in ical_matches.into_iter()
|
|
||||||
.zip(href_matches.into_iter())
|
|
||||||
.zip(etag_matches.into_iter()) {
|
|
||||||
|
|
||||||
let _ical_data = ical_match.as_str();
|
|
||||||
let href = href_match.as_str();
|
|
||||||
let _etag = etag_match.as_str();
|
|
||||||
|
|
||||||
debug!("Found iCalendar data in href: {}", href);
|
|
||||||
|
|
||||||
// Extract content between tags
|
|
||||||
let ical_content = ical_regex.captures(ical_match.as_str())
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.map(|m| m.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// Extract event ID from href
|
|
||||||
let event_id = href_regex.captures(href)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.map(|m| m.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.split('/')
|
|
||||||
.last()
|
|
||||||
.unwrap_or("")
|
|
||||||
.replace(".ics", "");
|
|
||||||
|
|
||||||
if !event_id.is_empty() {
|
|
||||||
// Parse the iCalendar data to extract basic event info
|
|
||||||
let event_info = self.parse_simple_ical_event(&event_id, ical_content)?;
|
|
||||||
events.push(event_info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Parsed {} events from XML response", events.len());
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse basic event information from iCalendar data
|
|
||||||
fn parse_simple_ical_event(&self, event_id: &str, ical_data: &str) -> CalDavResult<CalDavEventInfo> {
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
let summary_regex = Regex::new(r"SUMMARY:(.*)").unwrap();
|
|
||||||
let description_regex = Regex::new(r"DESCRIPTION:(.*)").unwrap();
|
|
||||||
let location_regex = Regex::new(r"LOCATION:(.*)").unwrap();
|
|
||||||
let dtstart_regex = Regex::new(r"DTSTART[^:]*:(.*)").unwrap();
|
|
||||||
let dtend_regex = Regex::new(r"DTEND[^:]*:(.*)").unwrap();
|
|
||||||
let status_regex = Regex::new(r"STATUS:(.*)").unwrap();
|
|
||||||
|
|
||||||
let summary = summary_regex.captures(ical_data)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.map(|m| m.as_str())
|
|
||||||
.unwrap_or("Untitled Event")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let description = description_regex.captures(ical_data)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.map(|m| m.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let location = location_regex.captures(ical_data)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.map(|m| m.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let status = status_regex.captures(ical_data)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.map(|m| m.as_str())
|
|
||||||
.unwrap_or("CONFIRMED")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Parse datetime (simplified)
|
|
||||||
let now = Utc::now();
|
|
||||||
let start = dtstart_regex.captures(ical_data)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.and_then(|m| self.parse_datetime(m.as_str()).ok())
|
|
||||||
.unwrap_or(now);
|
|
||||||
|
|
||||||
let end = dtend_regex.captures(ical_data)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.and_then(|m| self.parse_datetime(m.as_str()).ok())
|
|
||||||
.unwrap_or(now + chrono::Duration::hours(1));
|
|
||||||
|
|
||||||
Ok(CalDavEventInfo {
|
|
||||||
id: event_id.to_string(),
|
|
||||||
summary,
|
|
||||||
description,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
location,
|
|
||||||
status,
|
|
||||||
etag: None,
|
|
||||||
ical_data: ical_data.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse datetime from iCalendar format
|
|
||||||
fn parse_datetime(&self, dt_str: &str) -> CalDavResult<DateTime<Utc>> {
|
|
||||||
// Basic parsing for iCalendar datetime format
|
|
||||||
if dt_str.len() == 15 {
|
|
||||||
// Format: 20241015T143000Z
|
|
||||||
chrono::DateTime::parse_from_str(&format!("{} +0000", &dt_str), "%Y%m%dT%H%M%S %z")
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.map_err(|_| CalDavError::InvalidFormat("Invalid datetime format".to_string()))
|
|
||||||
} else {
|
|
||||||
// Try other formats or return current time as fallback
|
|
||||||
Ok(Utc::now())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calendar information
|
/// Calendar information
|
||||||
|
|
@ -410,637 +288,7 @@ pub struct CalDavEventInfo {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::ServerConfig;
|
|
||||||
use chrono::{DateTime, Utc, Timelike, Datelike};
|
|
||||||
|
|
||||||
/// Create a test server configuration
|
|
||||||
fn create_test_server_config() -> ServerConfig {
|
|
||||||
ServerConfig {
|
|
||||||
url: "https://caldav.test.com".to_string(),
|
|
||||||
username: "testuser".to_string(),
|
|
||||||
password: "testpass".to_string(),
|
|
||||||
use_https: true,
|
|
||||||
timeout: 30,
|
|
||||||
headers: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mock XML response for calendar listing
|
|
||||||
const MOCK_CALENDAR_XML: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/testuser/calendar1/</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<D:resourcetype>
|
|
||||||
<D:collection/>
|
|
||||||
<C:calendar/>
|
|
||||||
</D:resourcetype>
|
|
||||||
<D:displayname>Work Calendar</D:displayname>
|
|
||||||
<C:calendar-description>Work related events</C:calendar-description>
|
|
||||||
<C:supported-calendar-component-set>
|
|
||||||
<C:comp name="VEVENT"/>
|
|
||||||
<C:comp name="VTODO"/>
|
|
||||||
</C:supported-calendar-component-set>
|
|
||||||
<A:calendar-color xmlns:A="http://apple.com/ns/ical/">#3174ad</A:calendar-color>
|
|
||||||
</D:prop>
|
|
||||||
<D:status>HTTP/1.1 200 OK</D:status>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/testuser/personal/</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<D:resourcetype>
|
|
||||||
<D:collection/>
|
|
||||||
<C:calendar/>
|
|
||||||
</D:resourcetype>
|
|
||||||
<D:displayname>Personal</D:displayname>
|
|
||||||
<C:calendar-description>Personal events</C:calendar-description>
|
|
||||||
<C:supported-calendar-component-set>
|
|
||||||
<C:comp name="VEVENT"/>
|
|
||||||
</C:supported-calendar-component-set>
|
|
||||||
<A:calendar-color xmlns:A="http://apple.com/ns/ical/">#ff6b6b</A:calendar-color>
|
|
||||||
</D:prop>
|
|
||||||
<D:status>HTTP/1.1 200 OK</D:status>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>
|
|
||||||
</D:multistatus>"#;
|
|
||||||
|
|
||||||
/// Mock XML response for event listing
|
|
||||||
const MOCK_EVENTS_XML: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/testuser/work/1234567890.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<D:getetag>"1234567890-1"</D:getetag>
|
|
||||||
<C:calendar-data>BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:1234567890
|
|
||||||
SUMMARY:Team Meeting
|
|
||||||
DESCRIPTION:Weekly team sync to discuss project progress
|
|
||||||
LOCATION:Conference Room A
|
|
||||||
DTSTART:20241015T140000Z
|
|
||||||
DTEND:20241015T150000Z
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
</C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
<D:status>HTTP/1.1 200 OK</D:status>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/testuser/work/0987654321.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<D:getetag>"0987654321-1"</D:getetag>
|
|
||||||
<C:calendar-data>BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:0987654321
|
|
||||||
SUMMARY:Project Deadline
|
|
||||||
DESCRIPTION:Final project deliverable due
|
|
||||||
LOCATION:Office
|
|
||||||
DTSTART:20241020T170000Z
|
|
||||||
DTEND:20241020T180000Z
|
|
||||||
STATUS:TENTATIVE
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
</C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
<D:status>HTTP/1.1 200 OK</D:status>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>
|
|
||||||
</D:multistatus>"#;
|
|
||||||
|
|
||||||
mod calendar_discovery {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_client_creation() {
|
|
||||||
let config = create_test_server_config();
|
|
||||||
let client = CalDavClient::new(config);
|
|
||||||
assert!(client.is_ok());
|
|
||||||
|
|
||||||
let client = client.unwrap();
|
|
||||||
assert_eq!(client.base_url.as_str(), "https://caldav.test.com/");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_parsing_empty_xml() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let result = client.parse_calendar_list("");
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let calendars = result.unwrap();
|
|
||||||
assert_eq!(calendars.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_info_structure() {
|
|
||||||
let calendar_info = CalendarInfo {
|
|
||||||
path: "/calendars/test/work".to_string(),
|
|
||||||
display_name: "Work Calendar".to_string(),
|
|
||||||
description: Some("Work events".to_string()),
|
|
||||||
supported_components: vec!["VEVENT".to_string(), "VTODO".to_string()],
|
|
||||||
color: Some("#3174ad".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(calendar_info.path, "/calendars/test/work");
|
|
||||||
assert_eq!(calendar_info.display_name, "Work Calendar");
|
|
||||||
assert_eq!(calendar_info.description, Some("Work events".to_string()));
|
|
||||||
assert_eq!(calendar_info.supported_components.len(), 2);
|
|
||||||
assert_eq!(calendar_info.color, Some("#3174ad".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_info_serialization() {
|
|
||||||
let calendar_info = CalendarInfo {
|
|
||||||
path: "/calendars/test/personal".to_string(),
|
|
||||||
display_name: "Personal".to_string(),
|
|
||||||
description: None,
|
|
||||||
supported_components: vec!["VEVENT".to_string()],
|
|
||||||
color: Some("#ff6b6b".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test serialization for configuration storage
|
|
||||||
let serialized = serde_json::to_string(&calendar_info);
|
|
||||||
assert!(serialized.is_ok());
|
|
||||||
|
|
||||||
let deserialized: Result<CalendarInfo, _> = serde_json::from_str(&serialized.unwrap());
|
|
||||||
assert!(deserialized.is_ok());
|
|
||||||
|
|
||||||
let restored = deserialized.unwrap();
|
|
||||||
assert_eq!(restored.path, calendar_info.path);
|
|
||||||
assert_eq!(restored.display_name, calendar_info.display_name);
|
|
||||||
assert_eq!(restored.color, calendar_info.color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod event_retrieval {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_event_parsing_empty_xml() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let result = client.parse_events("");
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let events = result.unwrap();
|
|
||||||
assert_eq!(events.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_event_parsing_single_event() {
|
|
||||||
let single_event_xml = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<D:response>
|
|
||||||
<D:href>/calendars/test/work/event123.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<D:getetag>"event123-1"</D:getetag>
|
|
||||||
<C:calendar-data>BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:event123
|
|
||||||
SUMMARY:Test Event
|
|
||||||
DESCRIPTION:This is a test event
|
|
||||||
LOCATION:Test Location
|
|
||||||
DTSTART:20241015T140000Z
|
|
||||||
DTEND:20241015T150000Z
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
</C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
<D:status>HTTP/1.1 200 OK</D:status>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>
|
|
||||||
</D:multistatus>"#;
|
|
||||||
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let result = client.parse_events(single_event_xml);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let events = result.unwrap();
|
|
||||||
// Should parse 1 event from the XML
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
|
|
||||||
let event = &events[0];
|
|
||||||
assert_eq!(event.id, "event123");
|
|
||||||
assert_eq!(event.summary, "Test Event");
|
|
||||||
assert_eq!(event.description, Some("This is a test event".to_string()));
|
|
||||||
assert_eq!(event.location, Some("Test Location".to_string()));
|
|
||||||
assert_eq!(event.status, "CONFIRMED");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_event_parsing_multiple_events() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let result = client.parse_events(MOCK_EVENTS_XML);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let events = result.unwrap();
|
|
||||||
// Should parse 2 events from the XML
|
|
||||||
assert_eq!(events.len(), 2);
|
|
||||||
|
|
||||||
// Validate first event
|
|
||||||
let event1 = &events[0];
|
|
||||||
assert_eq!(event1.id, "1234567890");
|
|
||||||
assert_eq!(event1.summary, "Team Meeting");
|
|
||||||
assert_eq!(event1.description, Some("Weekly team sync to discuss project progress".to_string()));
|
|
||||||
assert_eq!(event1.location, Some("Conference Room A".to_string()));
|
|
||||||
assert_eq!(event1.status, "CONFIRMED");
|
|
||||||
|
|
||||||
// Validate second event
|
|
||||||
let event2 = &events[1];
|
|
||||||
assert_eq!(event2.id, "0987654321");
|
|
||||||
assert_eq!(event2.summary, "Project Deadline");
|
|
||||||
assert_eq!(event2.description, Some("Final project deliverable due".to_string()));
|
|
||||||
assert_eq!(event2.location, Some("Office".to_string()));
|
|
||||||
assert_eq!(event2.status, "TENTATIVE");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_datetime_parsing() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Test valid UTC datetime format
|
|
||||||
let result = client.parse_datetime("20241015T140000Z");
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let dt = result.unwrap();
|
|
||||||
assert_eq!(dt.year(), 2024);
|
|
||||||
assert_eq!(dt.month(), 10);
|
|
||||||
assert_eq!(dt.day(), 15);
|
|
||||||
assert_eq!(dt.hour(), 14);
|
|
||||||
assert_eq!(dt.minute(), 0);
|
|
||||||
assert_eq!(dt.second(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_datetime_parsing_invalid_format() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Test invalid format - should return current time as fallback
|
|
||||||
let result = client.parse_datetime("invalid-datetime");
|
|
||||||
assert!(result.is_ok()); // Current time fallback
|
|
||||||
|
|
||||||
let dt = result.unwrap();
|
|
||||||
// Should be close to current time
|
|
||||||
let now = Utc::now();
|
|
||||||
let diff = (dt - now).num_seconds().abs();
|
|
||||||
assert!(diff < 60); // Within 1 minute
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_simple_ical_parsing() {
|
|
||||||
let ical_data = r#"BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:test123
|
|
||||||
SUMMARY:Test Meeting
|
|
||||||
DESCRIPTION:Test description
|
|
||||||
LOCATION:Test room
|
|
||||||
DTSTART:20241015T140000Z
|
|
||||||
DTEND:20241015T150000Z
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR"#;
|
|
||||||
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let result = client.parse_simple_ical_event("test123", ical_data);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let event = result.unwrap();
|
|
||||||
assert_eq!(event.id, "test123");
|
|
||||||
assert_eq!(event.summary, "Test Meeting");
|
|
||||||
assert_eq!(event.description, Some("Test description".to_string()));
|
|
||||||
assert_eq!(event.location, Some("Test room".to_string()));
|
|
||||||
assert_eq!(event.status, "CONFIRMED");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ical_parsing_missing_fields() {
|
|
||||||
let minimal_ical = r#"BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:minimal123
|
|
||||||
DTSTART:20241015T140000Z
|
|
||||||
DTEND:20241015T150000Z
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR"#;
|
|
||||||
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
let result = client.parse_simple_ical_event("minimal123", minimal_ical);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let event = result.unwrap();
|
|
||||||
assert_eq!(event.id, "minimal123");
|
|
||||||
assert_eq!(event.summary, "Untitled Event"); // Default value
|
|
||||||
assert_eq!(event.description, None); // Not present
|
|
||||||
assert_eq!(event.location, None); // Not present
|
|
||||||
assert_eq!(event.status, "CONFIRMED"); // Default value
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_event_info_structure() {
|
|
||||||
let event_info = CalDavEventInfo {
|
|
||||||
id: "test123".to_string(),
|
|
||||||
summary: "Test Event".to_string(),
|
|
||||||
description: Some("Test description".to_string()),
|
|
||||||
start: DateTime::parse_from_rfc3339("2024-10-15T14:00:00Z").unwrap().with_timezone(&Utc),
|
|
||||||
end: DateTime::parse_from_rfc3339("2024-10-15T15:00:00Z").unwrap().with_timezone(&Utc),
|
|
||||||
location: Some("Test Location".to_string()),
|
|
||||||
status: "CONFIRMED".to_string(),
|
|
||||||
etag: Some("test-etag-123".to_string()),
|
|
||||||
ical_data: "BEGIN:VCALENDAR\r\n...".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(event_info.id, "test123");
|
|
||||||
assert_eq!(event_info.summary, "Test Event");
|
|
||||||
assert_eq!(event_info.description, Some("Test description".to_string()));
|
|
||||||
assert_eq!(event_info.location, Some("Test Location".to_string()));
|
|
||||||
assert_eq!(event_info.status, "CONFIRMED");
|
|
||||||
assert_eq!(event_info.etag, Some("test-etag-123".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_event_info_serialization() {
|
|
||||||
let event_info = CalDavEventInfo {
|
|
||||||
id: "serialize-test".to_string(),
|
|
||||||
summary: "Serialization Test".to_string(),
|
|
||||||
description: None,
|
|
||||||
start: Utc::now(),
|
|
||||||
end: Utc::now() + chrono::Duration::hours(1),
|
|
||||||
location: None,
|
|
||||||
status: "TENTATIVE".to_string(),
|
|
||||||
etag: None,
|
|
||||||
ical_data: "BEGIN:VCALENDAR...".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test serialization for state storage
|
|
||||||
let serialized = serde_json::to_string(&event_info);
|
|
||||||
assert!(serialized.is_ok());
|
|
||||||
|
|
||||||
let deserialized: Result<CalDavEventInfo, _> = serde_json::from_str(&serialized.unwrap());
|
|
||||||
assert!(deserialized.is_ok());
|
|
||||||
|
|
||||||
let restored = deserialized.unwrap();
|
|
||||||
assert_eq!(restored.id, event_info.id);
|
|
||||||
assert_eq!(restored.summary, event_info.summary);
|
|
||||||
assert_eq!(restored.status, event_info.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod integration {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_with_real_config() {
|
|
||||||
let config = ServerConfig {
|
|
||||||
url: "https://apidata.googleusercontent.com/caldav/v2/testuser@gmail.com/events/".to_string(),
|
|
||||||
username: "testuser@gmail.com".to_string(),
|
|
||||||
password: "app-password".to_string(),
|
|
||||||
use_https: true,
|
|
||||||
timeout: 30,
|
|
||||||
headers: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = CalDavClient::new(config);
|
|
||||||
assert!(client.is_ok());
|
|
||||||
|
|
||||||
let client = client.unwrap();
|
|
||||||
assert_eq!(client.config.url, "https://apidata.googleusercontent.com/caldav/v2/testuser@gmail.com/events/");
|
|
||||||
assert!(client.base_url.as_str().contains("googleusercontent.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_url_handling() {
|
|
||||||
let mut config = create_test_server_config();
|
|
||||||
|
|
||||||
// Test URL without trailing slash
|
|
||||||
config.url = "https://caldav.test.com/calendars".to_string();
|
|
||||||
let client = CalDavClient::new(config).unwrap();
|
|
||||||
assert_eq!(client.base_url.as_str(), "https://caldav.test.com/calendars/");
|
|
||||||
|
|
||||||
// Test URL with trailing slash
|
|
||||||
let config = ServerConfig {
|
|
||||||
url: "https://caldav.test.com/calendars/".to_string(),
|
|
||||||
..create_test_server_config()
|
|
||||||
};
|
|
||||||
let client = CalDavClient::new(config).unwrap();
|
|
||||||
assert_eq!(client.base_url.as_str(), "https://caldav.test.com/calendars/");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_mock_calendar_workflow() {
|
|
||||||
// This test simulates the complete calendar discovery workflow
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Simulate parsing calendar list response
|
|
||||||
let calendars = client.parse_calendar_list(MOCK_CALENDAR_XML).unwrap();
|
|
||||||
|
|
||||||
// Since parse_calendar_list is a placeholder, we'll validate the structure
|
|
||||||
// and ensure no panics occur during parsing
|
|
||||||
assert!(calendars.len() >= 0); // Should not panic
|
|
||||||
|
|
||||||
// Validate that the client can handle the XML structure
|
|
||||||
let result = client.parse_calendar_list("invalid xml");
|
|
||||||
assert!(result.is_ok()); // Should handle gracefully
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_mock_event_workflow() {
|
|
||||||
// This test simulates the complete event retrieval workflow
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Simulate parsing events response
|
|
||||||
let events = client.parse_events(MOCK_EVENTS_XML).unwrap();
|
|
||||||
|
|
||||||
// Validate that events were parsed correctly
|
|
||||||
assert_eq!(events.len(), 2);
|
|
||||||
|
|
||||||
// Validate event data integrity
|
|
||||||
for event in &events {
|
|
||||||
assert!(!event.id.is_empty());
|
|
||||||
assert!(!event.summary.is_empty());
|
|
||||||
assert!(event.start <= event.end);
|
|
||||||
assert!(!event.status.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod error_handling {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_malformed_xml_handling() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Test with completely malformed XML
|
|
||||||
let malformed_xml = "This is not XML at all";
|
|
||||||
let result = client.parse_events(malformed_xml);
|
|
||||||
// Should not panic and should return empty result
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert_eq!(result.unwrap().len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_partially_malformed_xml() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Test XML with some valid and some invalid parts
|
|
||||||
let partial_xml = r#"<?xml version="1.0"?>
|
|
||||||
<D:multistatus>
|
|
||||||
<D:response>
|
|
||||||
<D:href>/event1.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<C:calendar-data>BEGIN:VCALENDAR
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:event1
|
|
||||||
SUMMARY:Event 1
|
|
||||||
DTSTART:20241015T140000Z
|
|
||||||
DTEND:20241015T150000Z
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR</C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>
|
|
||||||
<D:response>
|
|
||||||
<D:href>/event2.ics</D:href>
|
|
||||||
<!-- Missing closing tags -->
|
|
||||||
<D:response>
|
|
||||||
</D:multistatus>"#;
|
|
||||||
|
|
||||||
let result = client.parse_events(partial_xml);
|
|
||||||
// Should handle gracefully and parse what it can
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let events = result.unwrap();
|
|
||||||
// Should parse at least the valid event
|
|
||||||
assert!(events.len() >= 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_icalendar_data() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
let empty_ical_xml = r#"<?xml version="1.0"?>
|
|
||||||
<D:multistatus>
|
|
||||||
<D:response>
|
|
||||||
<D:href>/empty-event.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<C:calendar-data></C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>
|
|
||||||
</D:multistatus>"#;
|
|
||||||
|
|
||||||
let result = client.parse_events(empty_ical_xml);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let events = result.unwrap();
|
|
||||||
// Should handle empty calendar data gracefully
|
|
||||||
assert_eq!(events.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_datetime_formats() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Test various invalid datetime formats
|
|
||||||
let invalid_formats = vec![
|
|
||||||
"invalid",
|
|
||||||
"2024-10-15", // Wrong format
|
|
||||||
"20241015", // Missing time
|
|
||||||
"T140000Z", // Missing date
|
|
||||||
"20241015140000", // Missing Z
|
|
||||||
"20241015T25:00:00Z", // Invalid hour
|
|
||||||
];
|
|
||||||
|
|
||||||
for invalid_format in invalid_formats {
|
|
||||||
let result = client.parse_datetime(invalid_format);
|
|
||||||
// Should handle gracefully with current time fallback
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod performance {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_large_event_parsing() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Create a large XML response with many events
|
|
||||||
let mut large_xml = String::from(r#"<?xml version="1.0"?>
|
|
||||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#);
|
|
||||||
|
|
||||||
for i in 0..100 {
|
|
||||||
large_xml.push_str(&format!(r#"
|
|
||||||
<D:response>
|
|
||||||
<D:href>/event{}.ics</D:href>
|
|
||||||
<D:propstat>
|
|
||||||
<D:prop>
|
|
||||||
<C:calendar-data>BEGIN:VCALENDAR
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:event{}
|
|
||||||
SUMMARY:Event {}
|
|
||||||
DTSTART:20241015T{:02}0000Z
|
|
||||||
DTEND:20241015T{:02}0000Z
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR</C:calendar-data>
|
|
||||||
</D:prop>
|
|
||||||
</D:propstat>
|
|
||||||
</D:response>"#, i, i, i, i % 24, (i + 1) % 24));
|
|
||||||
}
|
|
||||||
|
|
||||||
large_xml.push_str("\n</D:multistatus>");
|
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let result = client.parse_events(&large_xml);
|
|
||||||
let duration = start.elapsed();
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let events = result.unwrap();
|
|
||||||
assert_eq!(events.len(), 100);
|
|
||||||
|
|
||||||
// Performance assertion - should parse 100 events in reasonable time
|
|
||||||
assert!(duration.as_millis() < 1000, "Parsing 100 events took too long: {:?}", duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_memory_usage() {
|
|
||||||
let client = CalDavClient::new(create_test_server_config()).unwrap();
|
|
||||||
|
|
||||||
// Test that parsing doesn't leak memory or use excessive memory
|
|
||||||
let xml_with_repeating_data = MOCK_EVENTS_XML.repeat(10);
|
|
||||||
|
|
||||||
let result = client.parse_events(&xml_with_repeating_data);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let events = result.unwrap();
|
|
||||||
assert_eq!(events.len(), 20); // 2 events * 10 repetitions
|
|
||||||
|
|
||||||
// Verify all events have valid data
|
|
||||||
for event in &events {
|
|
||||||
assert!(!event.id.is_empty());
|
|
||||||
assert!(!event.summary.is_empty());
|
|
||||||
assert!(event.start <= event.end);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the original test
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_client_creation() {
|
fn test_client_creation() {
|
||||||
let config = ServerConfig {
|
let config = ServerConfig {
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,6 @@ impl Default for CalendarFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalendarFilter {
|
impl CalendarFilter {
|
||||||
/// Check if the filter is enabled (has any rules)
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
|
||||||
!self.rules.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new calendar filter
|
/// Create a new calendar filter
|
||||||
pub fn new(match_any: bool) -> Self {
|
pub fn new(match_any: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,14 @@ use anyhow::Result;
|
||||||
/// Main configuration structure
|
/// Main configuration structure
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// Source server configuration (primary CalDAV server)
|
/// Server configuration
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
/// Source calendar configuration
|
/// Calendar configuration
|
||||||
pub calendar: CalendarConfig,
|
pub calendar: CalendarConfig,
|
||||||
/// Filter configuration
|
/// Filter configuration
|
||||||
pub filters: Option<FilterConfig>,
|
pub filters: Option<FilterConfig>,
|
||||||
/// Sync configuration
|
/// Sync configuration
|
||||||
pub sync: SyncConfig,
|
pub sync: SyncConfig,
|
||||||
/// Import configuration (for unidirectional import to target)
|
|
||||||
pub import: Option<ImportConfig>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server connection configuration
|
/// Server connection configuration
|
||||||
|
|
@ -41,12 +39,12 @@ pub struct ServerConfig {
|
||||||
pub struct CalendarConfig {
|
pub struct CalendarConfig {
|
||||||
/// Calendar name/path
|
/// Calendar name/path
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Calendar display name (optional - will be discovered from server if not specified)
|
/// Calendar display name
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
/// Calendar color (optional - will be discovered from server if not specified)
|
/// Calendar color
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
/// Calendar timezone (optional - will be discovered from server if not specified)
|
/// Calendar timezone
|
||||||
pub timezone: Option<String>,
|
pub timezone: String,
|
||||||
/// Whether to sync this calendar
|
/// Whether to sync this calendar
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -94,25 +92,6 @@ pub struct DateRangeConfig {
|
||||||
pub sync_all_events: bool,
|
pub sync_all_events: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import configuration for unidirectional sync to target server
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ImportConfig {
|
|
||||||
/// Target server configuration
|
|
||||||
pub target_server: ServerConfig,
|
|
||||||
/// Target calendar configuration
|
|
||||||
pub target_calendar: CalendarConfig,
|
|
||||||
/// Whether to overwrite existing events in target
|
|
||||||
pub overwrite_existing: bool,
|
|
||||||
/// Whether to delete events in target that are missing from source
|
|
||||||
pub delete_missing: bool,
|
|
||||||
/// Whether to run in dry-run mode (preview changes only)
|
|
||||||
pub dry_run: bool,
|
|
||||||
/// Batch size for import operations
|
|
||||||
pub batch_size: usize,
|
|
||||||
/// Whether to create target calendar if it doesn't exist
|
|
||||||
pub create_target_calendar: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -120,7 +99,6 @@ impl Default for Config {
|
||||||
calendar: CalendarConfig::default(),
|
calendar: CalendarConfig::default(),
|
||||||
filters: None,
|
filters: None,
|
||||||
sync: SyncConfig::default(),
|
sync: SyncConfig::default(),
|
||||||
import: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +122,7 @@ impl Default for CalendarConfig {
|
||||||
name: "calendar".to_string(),
|
name: "calendar".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
color: None,
|
color: None,
|
||||||
timezone: None,
|
timezone: "UTC".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,22 +182,6 @@ impl Config {
|
||||||
if let Ok(calendar) = std::env::var("CALDAV_CALENDAR") {
|
if let Ok(calendar) = std::env::var("CALDAV_CALENDAR") {
|
||||||
config.calendar.name = calendar;
|
config.calendar.name = calendar;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override target server settings for import
|
|
||||||
if let Some(ref mut import_config) = config.import {
|
|
||||||
if let Ok(target_url) = std::env::var("CALDAV_TARGET_URL") {
|
|
||||||
import_config.target_server.url = target_url;
|
|
||||||
}
|
|
||||||
if let Ok(target_username) = std::env::var("CALDAV_TARGET_USERNAME") {
|
|
||||||
import_config.target_server.username = target_username;
|
|
||||||
}
|
|
||||||
if let Ok(target_password) = std::env::var("CALDAV_TARGET_PASSWORD") {
|
|
||||||
import_config.target_server.password = target_password;
|
|
||||||
}
|
|
||||||
if let Ok(target_calendar) = std::env::var("CALDAV_TARGET_CALENDAR") {
|
|
||||||
import_config.target_calendar.name = target_calendar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
@ -238,23 +200,6 @@ impl Config {
|
||||||
if self.calendar.name.is_empty() {
|
if self.calendar.name.is_empty() {
|
||||||
anyhow::bail!("Calendar name cannot be empty");
|
anyhow::bail!("Calendar name cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate import configuration if present
|
|
||||||
if let Some(import_config) = &self.import {
|
|
||||||
if import_config.target_server.url.is_empty() {
|
|
||||||
anyhow::bail!("Target server URL cannot be empty when import is enabled");
|
|
||||||
}
|
|
||||||
if import_config.target_server.username.is_empty() {
|
|
||||||
anyhow::bail!("Target server username cannot be empty when import is enabled");
|
|
||||||
}
|
|
||||||
if import_config.target_server.password.is_empty() {
|
|
||||||
anyhow::bail!("Target server password cannot be empty when import is enabled");
|
|
||||||
}
|
|
||||||
if import_config.target_calendar.name.is_empty() {
|
|
||||||
anyhow::bail!("Target calendar name cannot be empty when import is enabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
src/error.rs
25
src/error.rs
|
|
@ -10,9 +10,6 @@ pub type CalDavResult<T> = Result<T, CalDavError>;
|
||||||
pub enum CalDavError {
|
pub enum CalDavError {
|
||||||
#[error("Configuration error: {0}")]
|
#[error("Configuration error: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
|
|
||||||
#[error("Configuration error: {0}")]
|
|
||||||
ConfigurationError(String),
|
|
||||||
|
|
||||||
#[error("Authentication failed: {0}")]
|
#[error("Authentication failed: {0}")]
|
||||||
Authentication(String),
|
Authentication(String),
|
||||||
|
|
@ -43,9 +40,6 @@ pub enum CalDavError {
|
||||||
|
|
||||||
#[error("Event not found: {0}")]
|
#[error("Event not found: {0}")]
|
||||||
EventNotFound(String),
|
EventNotFound(String),
|
||||||
|
|
||||||
#[error("Not found: {0}")]
|
|
||||||
NotFound(String),
|
|
||||||
|
|
||||||
#[error("Synchronization error: {0}")]
|
#[error("Synchronization error: {0}")]
|
||||||
Sync(String),
|
Sync(String),
|
||||||
|
|
@ -77,9 +71,6 @@ pub enum CalDavError {
|
||||||
#[error("Timeout error: operation timed out after {0} seconds")]
|
#[error("Timeout error: operation timed out after {0} seconds")]
|
||||||
Timeout(u64),
|
Timeout(u64),
|
||||||
|
|
||||||
#[error("Invalid format: {0}")]
|
|
||||||
InvalidFormat(String),
|
|
||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
#[error("Unknown error: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
|
|
||||||
|
|
@ -136,6 +127,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_error_retryable() {
|
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());
|
let auth_error = CalDavError::Authentication("Invalid credentials".to_string());
|
||||||
assert!(!auth_error.is_retryable());
|
assert!(!auth_error.is_retryable());
|
||||||
|
|
||||||
|
|
@ -147,6 +143,11 @@ mod tests {
|
||||||
fn test_retry_delay() {
|
fn test_retry_delay() {
|
||||||
let rate_limit_error = CalDavError::RateLimited(120);
|
let rate_limit_error = CalDavError::RateLimited(120);
|
||||||
assert_eq!(rate_limit_error.retry_delay(), Some(120));
|
assert_eq!(rate_limit_error.retry_delay(), Some(120));
|
||||||
|
|
||||||
|
let network_error = CalDavError::Network(
|
||||||
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
||||||
|
);
|
||||||
|
assert_eq!(network_error.retry_delay(), Some(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -156,5 +157,11 @@ mod tests {
|
||||||
|
|
||||||
let config_error = CalDavError::Config("Invalid".to_string());
|
let config_error = CalDavError::Config("Invalid".to_string());
|
||||||
assert!(config_error.is_config_error());
|
assert!(config_error.is_config_error());
|
||||||
|
|
||||||
|
let network_error = CalDavError::Network(
|
||||||
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
||||||
|
);
|
||||||
|
assert!(!network_error.is_auth_error());
|
||||||
|
assert!(!network_error.is_config_error());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
src/lib.rs
16
src/lib.rs
|
|
@ -5,20 +5,14 @@
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod sync;
|
pub mod minicaldav_client;
|
||||||
pub mod timezone;
|
pub mod real_sync;
|
||||||
pub mod calendar_filter;
|
|
||||||
pub mod event;
|
|
||||||
pub mod caldav_client;
|
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig, ImportConfig};
|
pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig};
|
||||||
pub use error::{CalDavError, CalDavResult};
|
pub use error::{CalDavError, CalDavResult};
|
||||||
pub use sync::{SyncEngine, SyncResult, SyncState, SyncStats, ImportState, ImportResult, ImportAction, ImportError};
|
pub use minicaldav_client::{RealCalDavClient, CalendarInfo, CalendarEvent};
|
||||||
pub use timezone::TimezoneHandler;
|
pub use real_sync::{SyncEngine, SyncResult, SyncEvent, SyncStats};
|
||||||
pub use calendar_filter::{CalendarFilter, FilterRule};
|
|
||||||
pub use event::Event;
|
|
||||||
pub use caldav_client::CalDavClient;
|
|
||||||
|
|
||||||
/// Library version
|
/// Library version
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
|
||||||
199
src/main.rs
199
src/main.rs
|
|
@ -58,44 +58,6 @@ struct Cli {
|
||||||
/// Use specific calendar URL instead of discovering from config
|
/// Use specific calendar URL instead of discovering from config
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
calendar_url: Option<String>,
|
calendar_url: Option<String>,
|
||||||
|
|
||||||
// ==================== IMPORT COMMANDS ====================
|
|
||||||
|
|
||||||
/// Import events from source to target calendar
|
|
||||||
#[arg(long)]
|
|
||||||
import: bool,
|
|
||||||
|
|
||||||
/// Preview import without making changes (dry run)
|
|
||||||
#[arg(long)]
|
|
||||||
dry_run: bool,
|
|
||||||
|
|
||||||
/// Perform full import (ignore import state, import all events)
|
|
||||||
#[arg(long)]
|
|
||||||
full_import: bool,
|
|
||||||
|
|
||||||
/// Show import status and statistics
|
|
||||||
#[arg(long)]
|
|
||||||
import_status: bool,
|
|
||||||
|
|
||||||
/// Reset import state (for full re-import)
|
|
||||||
#[arg(long)]
|
|
||||||
reset_import_state: bool,
|
|
||||||
|
|
||||||
/// Target server URL for import (overrides config file)
|
|
||||||
#[arg(long)]
|
|
||||||
target_server_url: Option<String>,
|
|
||||||
|
|
||||||
/// Target username for import authentication (overrides config file)
|
|
||||||
#[arg(long)]
|
|
||||||
target_username: Option<String>,
|
|
||||||
|
|
||||||
/// Target password for import authentication (overrides config file)
|
|
||||||
#[arg(long)]
|
|
||||||
target_password: Option<String>,
|
|
||||||
|
|
||||||
/// Target calendar name for import (overrides config file)
|
|
||||||
#[arg(long)]
|
|
||||||
target_calendar: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -139,22 +101,6 @@ async fn main() -> Result<()> {
|
||||||
config.calendar.name = calendar.clone();
|
config.calendar.name = calendar.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override import configuration with command line arguments
|
|
||||||
if let Some(ref mut import_config) = &mut config.import {
|
|
||||||
if let Some(ref target_server_url) = cli.target_server_url {
|
|
||||||
import_config.target_server.url = target_server_url.clone();
|
|
||||||
}
|
|
||||||
if let Some(ref target_username) = cli.target_username {
|
|
||||||
import_config.target_server.username = target_username.clone();
|
|
||||||
}
|
|
||||||
if let Some(ref target_password) = cli.target_password {
|
|
||||||
import_config.target_server.password = target_password.clone();
|
|
||||||
}
|
|
||||||
if let Some(ref target_calendar) = cli.target_calendar {
|
|
||||||
import_config.target_calendar.name = target_calendar.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
if let Err(e) = config.validate() {
|
if let Err(e) = config.validate() {
|
||||||
error!("Configuration validation failed: {}", e);
|
error!("Configuration validation failed: {}", e);
|
||||||
|
|
@ -183,123 +129,30 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
|
||||||
// Create sync engine
|
// Create sync engine
|
||||||
let mut sync_engine = SyncEngine::new(config.clone()).await?;
|
let mut sync_engine = SyncEngine::new(config.clone()).await?;
|
||||||
|
|
||||||
// ==================== IMPORT COMMANDS ====================
|
|
||||||
|
|
||||||
if cli.import_status {
|
|
||||||
// Show import status and statistics
|
|
||||||
info!("Showing import status and statistics");
|
|
||||||
|
|
||||||
if let Some(import_state) = sync_engine.get_import_status() {
|
|
||||||
println!("Import Status:");
|
|
||||||
println!(" Last Import: {:?}", import_state.last_import);
|
|
||||||
println!(" Total Imported: {}", import_state.total_imported);
|
|
||||||
println!(" Imported Events: {}", import_state.imported_events.len());
|
|
||||||
println!(" Failed Imports: {}", import_state.failed_imports.len());
|
|
||||||
|
|
||||||
if let Some(last_import) = import_state.last_import {
|
|
||||||
let duration = Utc::now() - last_import;
|
|
||||||
println!(" Time Since Last Import: {} minutes", duration.num_minutes());
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\nImport Statistics:");
|
|
||||||
println!(" Total Processed: {}", import_state.stats.total_processed);
|
|
||||||
println!(" Successful Imports: {}", import_state.stats.successful_imports);
|
|
||||||
println!(" Updated Events: {}", import_state.stats.updated_events);
|
|
||||||
println!(" Skipped Events: {}", import_state.stats.skipped_events);
|
|
||||||
println!(" Failed Imports: {}", import_state.stats.failed_imports);
|
|
||||||
|
|
||||||
let duration_ms = import_state.stats.last_import_duration_ms;
|
|
||||||
println!(" Last Import Duration: {}ms", duration_ms);
|
|
||||||
|
|
||||||
// Show recent failed imports
|
|
||||||
if !import_state.failed_imports.is_empty() {
|
|
||||||
println!("\nRecent Failed Imports:");
|
|
||||||
for (event_id, error) in import_state.failed_imports.iter().take(5) {
|
|
||||||
println!(" Event {}: {}", event_id, error);
|
|
||||||
}
|
|
||||||
if import_state.failed_imports.len() > 5 {
|
|
||||||
println!(" ... and {} more", import_state.failed_imports.len() - 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!("Import not configured or no import state available.");
|
|
||||||
println!("Please configure import settings in your configuration file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if cli.reset_import_state {
|
|
||||||
// Reset import state
|
|
||||||
info!("Resetting import state");
|
|
||||||
sync_engine.reset_import_state();
|
|
||||||
println!("Import state has been reset. Next import will process all events.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if cli.import {
|
|
||||||
// Perform import from source to target
|
|
||||||
info!("Starting import from source to target calendar");
|
|
||||||
let dry_run = cli.dry_run;
|
|
||||||
let full_import = cli.full_import;
|
|
||||||
|
|
||||||
if dry_run {
|
|
||||||
println!("DRY RUN MODE: No changes will be made to target calendar");
|
|
||||||
}
|
|
||||||
|
|
||||||
match sync_engine.import_events(dry_run, full_import).await {
|
|
||||||
Ok(result) => {
|
|
||||||
println!("Import completed successfully!");
|
|
||||||
println!(" Events Processed: {}", result.events_processed);
|
|
||||||
println!(" Events Imported: {}", result.events_imported);
|
|
||||||
println!(" Events Updated: {}", result.events_updated);
|
|
||||||
println!(" Events Skipped: {}", result.events_skipped);
|
|
||||||
println!(" Events Failed: {}", result.events_failed);
|
|
||||||
|
|
||||||
if full_import {
|
|
||||||
println!(" Full Import: Processed all events from source");
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.errors.is_empty() {
|
|
||||||
println!("\nFailed Imports:");
|
|
||||||
for error in &result.errors {
|
|
||||||
println!(" Error: {}", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if dry_run {
|
|
||||||
println!("\nThis was a dry run. No actual changes were made.");
|
|
||||||
println!("Run without --dry-run to perform the actual import.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Import failed: {}", e);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== EXISTING COMMANDS ====================
|
|
||||||
|
|
||||||
if cli.list_calendars {
|
if cli.list_calendars {
|
||||||
// List calendars and exit
|
// List calendars and exit
|
||||||
info!("Listing available calendars from server");
|
info!("Listing available calendars from server");
|
||||||
|
|
||||||
// Get calendars directly from the client
|
// Get calendars directly from the client
|
||||||
let calendars = sync_engine.client.list_calendars().await?;
|
let calendars = sync_engine.client.discover_calendars().await?;
|
||||||
println!("Found {} calendars:", calendars.len());
|
println!("Found {} calendars:", calendars.len());
|
||||||
|
|
||||||
for (i, calendar) in calendars.iter().enumerate() {
|
for (i, calendar) in calendars.iter().enumerate() {
|
||||||
println!(" {}. {}", i + 1, calendar.display_name);
|
println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
||||||
println!(" Path: {}", calendar.path);
|
println!(" Name: {}", calendar.name);
|
||||||
if let Some(ref description) = calendar.description {
|
println!(" URL: {}", calendar.url);
|
||||||
println!(" Description: {}", description);
|
if let Some(ref display_name) = calendar.display_name {
|
||||||
|
println!(" Display Name: {}", display_name);
|
||||||
}
|
}
|
||||||
if let Some(ref color) = calendar.color {
|
if let Some(ref color) = calendar.color {
|
||||||
println!(" Color: {}", color);
|
println!(" Color: {}", color);
|
||||||
}
|
}
|
||||||
|
if let Some(ref description) = calendar.description {
|
||||||
|
println!(" Description: {}", description);
|
||||||
|
}
|
||||||
|
if let Some(ref timezone) = calendar.timezone {
|
||||||
|
println!(" Timezone: {}", timezone);
|
||||||
|
}
|
||||||
println!(" Supported Components: {}", calendar.supported_components.join(", "));
|
println!(" Supported Components: {}", calendar.supported_components.join(", "));
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
@ -315,13 +168,13 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
|
||||||
if let Some(ref approach) = cli.approach {
|
if let Some(ref approach) = cli.approach {
|
||||||
info!("Using specific approach: {}", approach);
|
info!("Using specific approach: {}", approach);
|
||||||
|
|
||||||
// Use the provided calendar URL if available, otherwise list calendars
|
// Use the provided calendar URL if available, otherwise discover calendars
|
||||||
let calendar_path = if let Some(ref url) = cli.calendar_url {
|
let calendar_url = if let Some(ref url) = cli.calendar_url {
|
||||||
url.clone()
|
url.clone()
|
||||||
} else {
|
} else {
|
||||||
let calendars = sync_engine.client.list_calendars().await?;
|
let calendars = sync_engine.client.discover_calendars().await?;
|
||||||
if let Some(calendar) = calendars.iter().find(|c| c.path == config.calendar.name || c.display_name == config.calendar.name) {
|
if let Some(calendar) = calendars.iter().find(|c| c.name == config.calendar.name || c.display_name.as_ref().map_or(false, |n| n == &config.calendar.name)) {
|
||||||
calendar.path.clone()
|
calendar.url.clone()
|
||||||
} else {
|
} else {
|
||||||
warn!("Calendar '{}' not found", config.calendar.name);
|
warn!("Calendar '{}' not found", config.calendar.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -332,14 +185,18 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
|
||||||
let start_date = now - Duration::days(30);
|
let start_date = now - Duration::days(30);
|
||||||
let end_date = now + Duration::days(30);
|
let end_date = now + Duration::days(30);
|
||||||
|
|
||||||
match sync_engine.client.get_events(&calendar_path, start_date, end_date).await {
|
match sync_engine.client.get_events_with_approach(&calendar_url, start_date, end_date, Some(approach.clone())).await {
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
println!("Found {} events using approach {}:", events.len(), approach);
|
println!("Found {} events using approach {}:", events.len(), approach);
|
||||||
for event in events {
|
for event in events {
|
||||||
println!(" - {} ({} to {})",
|
let start_tz = event.start_tzid.as_deref().unwrap_or("UTC");
|
||||||
|
let end_tz = event.end_tzid.as_deref().unwrap_or("UTC");
|
||||||
|
println!(" - {} ({} {} to {} {})",
|
||||||
event.summary,
|
event.summary,
|
||||||
event.start.format("%Y-%m-%d %H:%M"),
|
event.start.format("%Y-%m-%d %H:%M"),
|
||||||
event.end.format("%Y-%m-%d %H:%M")
|
start_tz,
|
||||||
|
event.end.format("%Y-%m-%d %H:%M"),
|
||||||
|
end_tz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -359,10 +216,14 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
|
||||||
println!("Found {} events:", events.len());
|
println!("Found {} events:", events.len());
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
println!(" - {} ({} to {})",
|
let start_tz = event.start_tzid.as_deref().unwrap_or("UTC");
|
||||||
|
let end_tz = event.end_tzid.as_deref().unwrap_or("UTC");
|
||||||
|
println!(" - {} ({} {} to {} {})",
|
||||||
event.summary,
|
event.summary,
|
||||||
event.start.format("%Y-%m-%d %H:%M"),
|
event.start.format("%Y-%m-%d %H:%M"),
|
||||||
event.end.format("%Y-%m-%d %H:%M")
|
start_tz,
|
||||||
|
event.end.format("%Y-%m-%d %H:%M"),
|
||||||
|
end_tz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1251
src/sync.rs
1251
src/sync.rs
File diff suppressed because it is too large
Load diff
|
|
@ -17,18 +17,12 @@ pub struct TimezoneHandler {
|
||||||
|
|
||||||
impl TimezoneHandler {
|
impl TimezoneHandler {
|
||||||
/// Create a new timezone handler with the given default timezone
|
/// Create a new timezone handler with the given default timezone
|
||||||
pub fn new(default_timezone: Option<&str>) -> CalDavResult<Self> {
|
pub fn new(default_timezone: &str) -> CalDavResult<Self> {
|
||||||
let default_tz: Tz = default_timezone
|
let default_tz: Tz = default_timezone.parse()
|
||||||
.unwrap_or("UTC")
|
.map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", default_timezone)))?;
|
||||||
.parse()
|
|
||||||
.map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", default_timezone.unwrap_or("UTC"))))?;
|
|
||||||
|
|
||||||
let mut cache = HashMap::new();
|
let mut cache = HashMap::new();
|
||||||
if let Some(tz) = default_timezone {
|
cache.insert(default_timezone.to_string(), default_tz);
|
||||||
cache.insert(tz.to_string(), default_tz);
|
|
||||||
} else {
|
|
||||||
cache.insert("UTC".to_string(), default_tz);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
default_tz,
|
default_tz,
|
||||||
|
|
@ -39,7 +33,7 @@ impl TimezoneHandler {
|
||||||
/// Create a timezone handler with system local timezone
|
/// Create a timezone handler with system local timezone
|
||||||
pub fn with_local_timezone() -> CalDavResult<Self> {
|
pub fn with_local_timezone() -> CalDavResult<Self> {
|
||||||
let local_tz = Self::get_system_timezone()?;
|
let local_tz = Self::get_system_timezone()?;
|
||||||
Self::new(Some(local_tz.as_str()))
|
Self::new(&local_tz)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a datetime with timezone information
|
/// Parse a datetime with timezone information
|
||||||
|
|
@ -174,7 +168,7 @@ impl TimezoneHandler {
|
||||||
|
|
||||||
impl Default for TimezoneHandler {
|
impl Default for TimezoneHandler {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(None).unwrap()
|
Self::new("UTC").unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,13 +266,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_timezone_handler_creation() {
|
fn test_timezone_handler_creation() {
|
||||||
let handler = TimezoneHandler::new(Some("UTC")).unwrap();
|
let handler = TimezoneHandler::new("UTC").unwrap();
|
||||||
assert_eq!(handler.default_timezone(), "UTC");
|
assert_eq!(handler.default_timezone(), "UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_utc_datetime_parsing() {
|
fn test_utc_datetime_parsing() {
|
||||||
let mut handler = TimezoneHandler::default();
|
let handler = TimezoneHandler::default();
|
||||||
let dt = handler.parse_datetime("20231225T100000Z", None).unwrap();
|
let dt = handler.parse_datetime("20231225T100000Z", None).unwrap();
|
||||||
assert_eq!(dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z");
|
assert_eq!(dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z");
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +287,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ical_formatting() {
|
fn test_ical_formatting() {
|
||||||
let mut handler = TimezoneHandler::default();
|
let handler = TimezoneHandler::default();
|
||||||
let dt = DateTime::from_naive_utc_and_offset(
|
let dt = DateTime::from_naive_utc_and_offset(
|
||||||
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
Utc
|
Utc
|
||||||
|
|
@ -308,7 +302,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_timezone_conversion() {
|
fn test_timezone_conversion() {
|
||||||
let mut handler = TimezoneHandler::new(Some("UTC")).unwrap();
|
let mut handler = TimezoneHandler::new("UTC").unwrap();
|
||||||
let dt = DateTime::from_naive_utc_and_offset(
|
let dt = DateTime::from_naive_utc_and_offset(
|
||||||
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
Utc
|
Utc
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use caldav_sync::{Config, CalDavResult};
|
use caldav_sync::{Config, CalDavResult};
|
||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod config_tests {
|
mod config_tests {
|
||||||
|
|
@ -33,20 +32,20 @@ mod config_tests {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod error_tests {
|
mod error_tests {
|
||||||
use caldav_sync::CalDavError;
|
use caldav_sync::{CalDavError, CalDavResult};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_error_retryable() {
|
fn test_error_retryable() {
|
||||||
// Create a simple network error test - skip the reqwest::Error creation
|
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());
|
let auth_error = CalDavError::Authentication("Invalid credentials".to_string());
|
||||||
assert!(!auth_error.is_retryable());
|
assert!(!auth_error.is_retryable());
|
||||||
|
|
||||||
let config_error = CalDavError::Config("Missing URL".to_string());
|
let config_error = CalDavError::Config("Missing URL".to_string());
|
||||||
assert!(!config_error.is_retryable());
|
assert!(!config_error.is_retryable());
|
||||||
|
|
||||||
// Just test that is_retryable works for different error types
|
|
||||||
assert!(!CalDavError::Authentication("test".to_string()).is_retryable());
|
|
||||||
assert!(!CalDavError::Config("test".to_string()).is_retryable());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -115,12 +114,10 @@ mod event_tests {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod timezone_tests {
|
mod timezone_tests {
|
||||||
use caldav_sync::timezone::TimezoneHandler;
|
use caldav_sync::timezone::TimezoneHandler;
|
||||||
use caldav_sync::CalDavResult;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_timezone_handler_creation() -> CalDavResult<()> {
|
fn test_timezone_handler_creation() -> CalDavResult<()> {
|
||||||
let handler = TimezoneHandler::new(Some("UTC"))?;
|
let handler = TimezoneHandler::new("UTC")?;
|
||||||
assert_eq!(handler.default_timezone(), "UTC");
|
assert_eq!(handler.default_timezone(), "UTC");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +132,7 @@ mod timezone_tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ical_formatting() -> CalDavResult<()> {
|
fn test_ical_formatting() -> CalDavResult<()> {
|
||||||
let mut handler = TimezoneHandler::default();
|
let handler = TimezoneHandler::default();
|
||||||
let dt = DateTime::from_naive_utc_and_offset(
|
let dt = DateTime::from_naive_utc_and_offset(
|
||||||
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
Utc
|
Utc
|
||||||
|
|
@ -154,9 +151,9 @@ mod timezone_tests {
|
||||||
mod filter_tests {
|
mod filter_tests {
|
||||||
use caldav_sync::calendar_filter::{
|
use caldav_sync::calendar_filter::{
|
||||||
CalendarFilter, FilterRule, DateRangeFilter, KeywordFilter,
|
CalendarFilter, FilterRule, DateRangeFilter, KeywordFilter,
|
||||||
EventStatusFilter, FilterBuilder
|
EventTypeFilter, EventStatusFilter, FilterBuilder
|
||||||
};
|
};
|
||||||
use caldav_sync::event::{Event, EventStatus};
|
use caldav_sync::event::{Event, EventStatus, EventType};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -184,7 +181,7 @@ mod filter_tests {
|
||||||
start - chrono::Duration::days(1),
|
start - chrono::Duration::days(1),
|
||||||
start - chrono::Duration::hours(23),
|
start - chrono::Duration::hours(23),
|
||||||
);
|
);
|
||||||
assert!(!filter.matches_event(&event_outside));
|
assert!(!filter_outside.matches_event(&event_outside));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -220,10 +217,11 @@ mod filter_tests {
|
||||||
let filter = FilterBuilder::new()
|
let filter = FilterBuilder::new()
|
||||||
.match_any(false) // AND logic
|
.match_any(false) // AND logic
|
||||||
.keywords(vec!["meeting".to_string()])
|
.keywords(vec!["meeting".to_string()])
|
||||||
|
.event_types(vec![EventType::Public])
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
||||||
assert!(filter.matches_event(&event)); // Matches condition
|
assert!(filter.matches_event(&event)); // Matches both conditions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue