Fix timezone handling and update detection

- Fix timezone preservation in to_ical_simple() for import module
- Add timezone comparison to needs_update() method to detect timezone differences
- Add comprehensive test for timezone comparison logic
- Log Bug #3: recurring event end detection issue for future investigation
This commit is contained in:
Alvaro Soliverez 2025-11-21 11:56:27 -03:00
parent f84ce62f73
commit 932b6ae463
5 changed files with 1178 additions and 132 deletions

View file

@ -1,10 +1,15 @@
//! Event handling and iCalendar parsing
use crate::error::CalDavResult;
use chrono::{DateTime, Utc};
use chrono::{DateTime, Utc, Datelike, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use md5;
// RRULE support (simplified for now)
// use rrule::{RRuleSet, RRule, Frequency, Weekday as RRuleWeekday, NWeekday, Tz};
// use std::str::FromStr;
/// Calendar event representation
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -111,47 +116,107 @@ pub enum ParticipationStatus {
Delegated,
}
/// Recurrence rule
/// Recurrence rule (simplified RRULE string representation)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrenceRule {
/// Frequency
pub frequency: RecurrenceFrequency,
/// Interval
pub interval: u32,
/// Count (number of occurrences)
pub count: Option<u32>,
/// Until date
pub until: Option<DateTime<Utc>>,
/// Days of week
pub by_day: Option<Vec<WeekDay>>,
/// Days of month
pub by_month_day: Option<Vec<u32>>,
/// Months
pub by_month: Option<Vec<u32>>,
/// Original RRULE string for storage and parsing
pub original_rule: String,
}
/// Recurrence frequency
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum RecurrenceFrequency {
Secondly,
Minutely,
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
}
/// Day of week for recurrence
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum WeekDay {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
impl RecurrenceRule {
/// Create a new RecurrenceRule from an RRULE string
pub fn from_str(rrule_str: &str) -> Result<Self, Box<dyn std::error::Error>> {
Ok(RecurrenceRule {
original_rule: rrule_str.to_string(),
})
}
/// Get the RRULE string
pub fn as_str(&self) -> &str {
&self.original_rule
}
/// Parse RRULE components from the original_rule string
fn parse_components(&self) -> std::collections::HashMap<String, String> {
let mut components = std::collections::HashMap::new();
for part in self.original_rule.split(';') {
if let Some((key, value)) = part.split_once('=') {
components.insert(key.to_uppercase(), value.to_string());
}
}
components
}
/// Get the frequency (FREQ) component
pub fn frequency(&self) -> String {
self.parse_components()
.get("FREQ")
.cloned()
.unwrap_or_else(|| "DAILY".to_string())
}
/// Get the interval (INTERVAL) component
pub fn interval(&self) -> i32 {
self.parse_components()
.get("INTERVAL")
.and_then(|s| s.parse().ok())
.unwrap_or(1)
}
/// Get the count (COUNT) component
pub fn count(&self) -> Option<i32> {
self.parse_components()
.get("COUNT")
.and_then(|s| s.parse().ok())
}
/// Get the until date (UNTIL) component
pub fn until(&self) -> Option<DateTime<Utc>> {
self.parse_components()
.get("UNTIL")
.and_then(|s| {
// Try parsing as different date formats
// Format 1: YYYYMMDD (8 characters)
if s.len() == 8 {
return DateTime::parse_from_str(&format!("{}T000000Z", s), "%Y%m%dT%H%M%SZ")
.ok()
.map(|dt| dt.with_timezone(&Utc));
}
// Format 2: Basic iCalendar datetime with Z: YYYYMMDDTHHMMSSZ (15 or 16 characters)
if s.ends_with('Z') && (s.len() == 15 || s.len() == 16) {
let cleaned = s.trim_end_matches('Z');
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(cleaned, "%Y%m%dT%H%M%S") {
return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
}
}
// Format 3: Basic iCalendar datetime without Z: YYYYMMDDTHHMMSS (15 characters)
if s.len() == 15 && s.contains('T') {
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S") {
return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
}
}
// Format 4: Try RFC3339 format
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
None
})
}
/// Get the BYDAY component
pub fn by_day(&self) -> Vec<String> {
self.parse_components()
.get("BYDAY")
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default()
}
}
/// Event alarm/reminder
@ -188,6 +253,38 @@ pub enum AlarmTrigger {
Absolute(DateTime<Utc>),
}
impl std::fmt::Display for AlarmAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AlarmAction::Display => write!(f, "DISPLAY"),
AlarmAction::Email => write!(f, "EMAIL"),
AlarmAction::Audio => write!(f, "AUDIO"),
}
}
}
impl std::fmt::Display for AlarmTrigger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AlarmTrigger::BeforeStart(duration) => {
let total_seconds = duration.num_seconds();
write!(f, "-P{}S", total_seconds.abs())
}
AlarmTrigger::AfterStart(duration) => {
let total_seconds = duration.num_seconds();
write!(f, "P{}S", total_seconds)
}
AlarmTrigger::BeforeEnd(duration) => {
let total_seconds = duration.num_seconds();
write!(f, "-P{}S", total_seconds)
}
AlarmTrigger::Absolute(datetime) => {
write!(f, "{}", datetime.format("%Y%m%dT%H%M%SZ"))
}
}
}
}
impl Event {
/// Create a new event
pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
@ -274,17 +371,27 @@ impl Event {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
}
// Dates
// Dates with timezone preservation
if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
self.end.format("%Y%m%d")));
} else {
ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ")));
// Check if we have timezone information
if let Some(ref tzid) = self.timezone {
// Use timezone-aware format
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n",
tzid, self.start.format("%Y%m%dT%H%M%S")));
ical.push_str(&format!("DTEND;TZID={}:{}\r\n",
tzid, self.end.format("%Y%m%dT%H%M%S")));
} else {
// Fall back to UTC format
ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ")));
}
}
// Status
@ -334,6 +441,190 @@ impl Event {
self.sequence += 1;
}
/// Generate simplified iCalendar format optimized for Nextcloud import
/// This creates clean, individual .ics files that avoid Zoho parsing issues
pub fn to_ical_simple(&self) -> CalDavResult<String> {
let mut ical = String::new();
// iCalendar header - minimal and clean
ical.push_str("BEGIN:VCALENDAR\r\n");
ical.push_str("VERSION:2.0\r\n");
ical.push_str("PRODID:-//caldav-sync//simple-import//EN\r\n");
ical.push_str("CALSCALE:GREGORIAN\r\n");
// VEVENT header
ical.push_str("BEGIN:VEVENT\r\n");
// Required properties - only the essentials for Nextcloud
ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid)));
ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary)));
// Simplified datetime handling - timezone-aware for compatibility
if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
self.end.format("%Y%m%d")));
} else {
// Use timezone-aware format when available, fall back to UTC
if let Some(ref tzid) = self.timezone {
// Use timezone-aware format
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n",
tzid, self.start.format("%Y%m%dT%H%M%S")));
ical.push_str(&format!("DTEND;TZID={}:{}\r\n",
tzid, self.end.format("%Y%m%dT%H%M%S")));
} else {
// Fall back to UTC format for maximum compatibility
ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ")));
}
}
// Required timestamps
ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence));
// Basic status - always confirmed for simplicity
ical.push_str("STATUS:CONFIRMED\r\n");
ical.push_str("CLASS:PUBLIC\r\n");
// VEVENT and VCALENDAR footers
ical.push_str("END:VEVENT\r\n");
ical.push_str("END:VCALENDAR\r\n");
Ok(ical)
}
/// Generate iCalendar format optimized for Nextcloud
pub fn to_ical_for_nextcloud(&self) -> CalDavResult<String> {
let mut ical = String::new();
// iCalendar header with Nextcloud-specific properties
ical.push_str("BEGIN:VCALENDAR\r\n");
ical.push_str("VERSION:2.0\r\n");
ical.push_str("PRODID:-//caldav-sync//caldav-sync 0.1.0//EN\r\n");
ical.push_str("CALSCALE:GREGORIAN\r\n");
// Add timezone information if available
if let Some(tzid) = &self.timezone {
ical.push_str(&format!("X-WR-TIMEZONE:{}\r\n", tzid));
}
// VEVENT header
ical.push_str("BEGIN:VEVENT\r\n");
// Required properties
ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid)));
ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary)));
// Enhanced datetime handling with timezone support
if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
(self.end.date_naive() + chrono::Duration::days(1)).format("%Y%m%d")));
} else {
if let Some(tzid) = &self.timezone {
// Use timezone-specific format
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n",
tzid, self.start.format("%Y%m%dT%H%M%S")));
ical.push_str(&format!("DTEND;TZID={}:{}\r\n",
tzid, self.end.format("%Y%m%dT%H%M%S")));
} else {
// Use UTC format
ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ")));
}
}
// Required timestamps
ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence));
// Optional properties
if let Some(description) = &self.description {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
}
if let Some(location) = &self.location {
ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location)));
}
// Status mapping
ical.push_str(&format!("STATUS:{}\r\n", match self.status {
EventStatus::Confirmed => "CONFIRMED",
EventStatus::Tentative => "TENTATIVE",
EventStatus::Cancelled => "CANCELLED",
}));
// Class (visibility)
ical.push_str(&format!("CLASS:{}\r\n", match self.event_type {
EventType::Public => "PUBLIC",
EventType::Private => "PRIVATE",
EventType::Confidential => "CONFIDENTIAL",
}));
// Organizer and attendees
if let Some(organizer) = &self.organizer {
if let Some(name) = &organizer.name {
ical.push_str(&format!("ORGANIZER;CN={}:mailto:{}\r\n",
escape_ical_text(name), organizer.email));
} else {
ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email));
}
}
for attendee in &self.attendees {
let mut attendee_line = String::from("ATTENDEE");
if let Some(name) = &attendee.name {
attendee_line.push_str(&format!(";CN={}", escape_ical_text(name)));
}
attendee_line.push_str(&format!(":mailto:{}", attendee.email));
attendee_line.push_str("\r\n");
ical.push_str(&attendee_line);
}
// Alarms/reminders
for alarm in &self.alarms {
ical.push_str(&format!("BEGIN:VALARM\r\n"));
ical.push_str(&format!("ACTION:{}\r\n", alarm.action));
ical.push_str(&format!("TRIGGER:{}\r\n", alarm.trigger));
if let Some(description) = &alarm.description {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
}
ical.push_str("END:VALARM\r\n");
}
// Custom properties (including Nextcloud-specific ones)
for (key, value) in &self.properties {
if key.starts_with("X-") {
ical.push_str(&format!("{}:{}\r\n", key, escape_ical_text(value)));
}
}
// VEVENT and VCALENDAR footers
ical.push_str("END:VEVENT\r\n");
ical.push_str("END:VCALENDAR\r\n");
Ok(ical)
}
/// Generate the CalDAV path for this event
pub fn generate_caldav_path(&self) -> String {
format!("{}.ics", self.uid)
}
/// Check if event occurs on a specific date
pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool {
let start_date = self.start.date_naive();
@ -356,6 +647,301 @@ impl Event {
let now = Utc::now();
now >= self.start && now <= self.end
}
/// Check if this event needs updating compared to another event
pub fn needs_update(&self, other: &Event) -> bool {
// Compare essential fields
if self.summary != other.summary {
return true;
}
if self.description != other.description {
return true;
}
if self.location != other.location {
return true;
}
// Compare timezone information - this is crucial for detecting timezone mangling fixes
match (&self.timezone, &other.timezone) {
(None, None) => {
// Both have no timezone - continue with other checks
}
(Some(tz1), Some(tz2)) => {
// Both have timezone - compare them
if tz1 != tz2 {
return true;
}
}
(Some(_), None) | (None, Some(_)) => {
// One has timezone, other doesn't - definitely needs update
return true;
}
}
// Compare dates with some tolerance for timestamp differences
let start_diff = (self.start - other.start).num_seconds().abs();
let end_diff = (self.end - other.end).num_seconds().abs();
if start_diff > 60 || end_diff > 60 { // 1 minute tolerance
return true;
}
// Compare status and event type
if self.status != other.status {
return true;
}
if self.event_type != other.event_type {
return true;
}
// Compare sequence numbers - higher sequence means newer
if self.sequence > other.sequence {
return true;
}
false
}
/// Validate event for CalDAV import compatibility
pub fn validate_for_import(&self) -> Result<(), String> {
// Check required fields
if self.uid.trim().is_empty() {
return Err("Event UID cannot be empty".to_string());
}
if self.summary.trim().is_empty() {
return Err("Event summary cannot be empty".to_string());
}
// Validate datetime
if self.start > self.end {
return Err("Event start time must be before end time".to_string());
}
// Check for reasonable date ranges
let now = Utc::now();
let one_year_ago = now - chrono::Duration::days(365);
let ten_years_future = now + chrono::Duration::days(365 * 10);
if self.start < one_year_ago {
return Err("Event start time is more than one year in the past".to_string());
}
if self.start > ten_years_future {
return Err("Event start time is more than ten years in the future".to_string());
}
Ok(())
}
/// Simple recurrence expansion for basic RRULE strings
pub fn expand_occurrences(&self, start_range: DateTime<Utc>, end_range: DateTime<Utc>) -> Vec<Event> {
// If this is not a recurring event, return just this event
if self.recurrence.is_none() {
return vec![self.clone()];
}
let mut occurrences = Vec::new();
let recurrence_rule = self.recurrence.as_ref().unwrap();
// For now, implement a very basic RRULE expansion using simple date arithmetic
let mut current_start = self.start;
let event_duration = self.duration();
let mut occurrence_count = 0;
// Limit occurrences to prevent infinite loops
let max_occurrences = recurrence_rule.count().unwrap_or(1000).min(1000);
while current_start <= end_range && occurrence_count < max_occurrences {
// Check if we've reached the count limit
if let Some(count) = recurrence_rule.count() {
if occurrence_count >= count {
break;
}
}
// Check if we've reached the until limit
if let Some(until) = recurrence_rule.until() {
if current_start > until {
break;
}
}
// Check if this occurrence falls within our desired range
if current_start >= start_range && current_start <= end_range {
let mut occurrence = self.clone();
occurrence.start = current_start;
occurrence.end = current_start + event_duration;
// Create a unique UID for this occurrence
let occurrence_date = current_start.format("%Y%m%d").to_string();
// Include a hash of the original event details to ensure uniqueness across different recurring series
let series_identifier = format!("{:x}", md5::compute(format!("{}-{}", self.uid, self.summary)));
occurrence.uid = format!("{}-occurrence-{}-{}", series_identifier, occurrence_date, self.uid);
// Clear the recurrence rule for individual occurrences
occurrence.recurrence = None;
// Update creation and modification times
occurrence.created = Utc::now();
occurrence.last_modified = Utc::now();
occurrences.push(occurrence);
}
// Calculate next occurrence based on RRULE components
let interval = recurrence_rule.interval() as i64;
current_start = match recurrence_rule.frequency().to_lowercase().as_str() {
"daily" => {
// For daily frequency, check if there are BYDAY restrictions
let by_day = recurrence_rule.by_day();
if !by_day.is_empty() {
// Find the next valid weekday for DAILY frequency with BYDAY restriction
let mut next_day = current_start + chrono::Duration::days(1);
let mut days_checked = 0;
// Search for up to 7 days to find the next valid weekday
while days_checked < 7 {
let weekday = match next_day.weekday().number_from_monday() {
1 => "MO",
2 => "TU",
3 => "WE",
4 => "TH",
5 => "FR",
6 => "SA",
7 => "SU",
_ => "MO", // fallback
};
if by_day.contains(&weekday.to_string()) {
// Found the next valid weekday
break;
}
next_day = next_day + chrono::Duration::days(1);
days_checked += 1;
}
next_day
} else {
// No BYDAY restriction, just add days normally
current_start + chrono::Duration::days(interval)
}
},
"weekly" => {
// For weekly frequency, we need to handle BYDAY filtering
let by_day = recurrence_rule.by_day();
if !by_day.is_empty() {
// Find the next valid weekday
let mut next_day = current_start + chrono::Duration::days(1);
let mut days_checked = 0;
// Search for up to 7 days (one week) to find the next valid weekday
while days_checked < 7 {
let weekday = match next_day.weekday().number_from_monday() {
1 => "MO",
2 => "TU",
3 => "WE",
4 => "TH",
5 => "FR",
6 => "SA",
7 => "SU",
_ => "MO", // fallback
};
if by_day.contains(&weekday.to_string()) {
// Found the next valid weekday
break;
}
next_day = next_day + chrono::Duration::days(1);
days_checked += 1;
}
next_day
} else {
// No BYDAY restriction, just add weeks
current_start + chrono::Duration::weeks(interval)
}
},
"monthly" => add_months(current_start, interval as u32),
"yearly" => add_months(current_start, (interval * 12) as u32),
"hourly" => current_start + chrono::Duration::hours(interval),
"minutely" => current_start + chrono::Duration::minutes(interval),
"secondly" => current_start + chrono::Duration::seconds(interval),
_ => current_start + chrono::Duration::days(interval), // Default to daily
};
occurrence_count += 1;
}
tracing::info!(
"🔄 Expanded recurring event '{}' to {} occurrences between {} and {}",
self.summary,
occurrences.len(),
start_range.format("%Y-%m-%d"),
end_range.format("%Y-%m-%d")
);
occurrences
}
}
/// Add months to a DateTime (approximate handling)
fn add_months(dt: DateTime<Utc>, months: u32) -> DateTime<Utc> {
let naive_date = dt.naive_utc();
let year = naive_date.year();
let month = naive_date.month() as i32 + months as i32;
let new_year = year + (month - 1) / 12;
let new_month = ((month - 1) % 12) + 1;
// Keep the same day if possible, otherwise use the last day of the month
let day = naive_date.day().min(days_in_month(new_year as i32, new_month as u32));
// Try to create the new date with the same time, fallback to first day of month if invalid
if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, day) {
if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) {
return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc);
}
}
// Fallback: use first day of the month with the same time
if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) {
if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) {
return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc);
}
}
// Ultimate fallback: use start of the month
if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) {
if let Some(new_naive_dt) = new_naive_date.and_hms_opt(0, 0, 0) {
return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc);
}
}
// If all else fails, return the original date
dt
}
/// Get the number of days in a month
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
29
} else {
28
}
}
_ => 30, // Should never happen
}
}
/// Escape text for iCalendar format
@ -464,4 +1050,86 @@ mod tests {
let local_result = parse_ical_datetime("20231225T103000");
assert!(local_result.is_err());
}
#[test]
fn test_event_to_ical_with_timezone() {
let start = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let end = start + chrono::Duration::minutes(30);
let mut event = Event::new("Tether Sync".to_string(), start, end);
event.timezone = Some("America/Toronto".to_string());
let ical = event.to_ical().unwrap();
// Should include timezone information
assert!(ical.contains("DTSTART;TZID=America/Toronto:20231225T083000"));
assert!(ical.contains("DTEND;TZID=America/Toronto:20231225T090000"));
assert!(ical.contains("SUMMARY:Tether Sync"));
}
#[test]
fn test_event_to_ical_without_timezone() {
let start = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let end = start + chrono::Duration::minutes(30);
let event = Event::new("UTC Event".to_string(), start, end);
let ical = event.to_ical().unwrap();
// Should use UTC format when no timezone is specified
assert!(ical.contains("DTSTART:20231225T083000Z"));
assert!(ical.contains("DTEND:20231225T090000Z"));
assert!(ical.contains("SUMMARY:UTC Event"));
}
#[test]
fn test_needs_update_timezone_comparison() {
let start = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let end = start + chrono::Duration::minutes(30);
// Test case 1: Event with timezone vs event without timezone (should need update)
let mut event_with_tz = Event::new("Test Event".to_string(), start, end);
event_with_tz.timezone = Some("America/Toronto".to_string());
let event_without_tz = Event::new("Test Event".to_string(), start, end);
assert!(event_with_tz.needs_update(&event_without_tz));
assert!(event_without_tz.needs_update(&event_with_tz));
// Test case 2: Events with different timezones (should need update)
let mut event_tz1 = Event::new("Test Event".to_string(), start, end);
event_tz1.timezone = Some("America/Toronto".to_string());
let mut event_tz2 = Event::new("Test Event".to_string(), start, end);
event_tz2.timezone = Some("Europe/Athens".to_string());
assert!(event_tz1.needs_update(&event_tz2));
assert!(event_tz2.needs_update(&event_tz1));
// Test case 3: Events with same timezone (should not need update)
let mut event_tz3 = Event::new("Test Event".to_string(), start, end);
event_tz3.timezone = Some("America/Toronto".to_string());
let mut event_tz4 = Event::new("Test Event".to_string(), start, end);
event_tz4.timezone = Some("America/Toronto".to_string());
assert!(!event_tz3.needs_update(&event_tz4));
assert!(!event_tz4.needs_update(&event_tz3));
// Test case 4: Both events without timezone (should not need update)
let event_no_tz1 = Event::new("Test Event".to_string(), start, end);
let event_no_tz2 = Event::new("Test Event".to_string(), start, end);
assert!(!event_no_tz1.needs_update(&event_no_tz2));
assert!(!event_no_tz2.needs_update(&event_no_tz1));
}
}