use std::collections::HashMap;
use tokio::fs;
use aimcal_core::{
Aim, Config, Event, EventConditions, Id, LooseDateTime, Pager, Priority, Todo, TodoConditions,
TodoDraft,
};
use jiff::{Span, Zoned};
use crate::common::{setup_temp_dirs, test_event_draft, test_todo_draft};
#[tokio::test]
async fn file_sync_external_modification_detected() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config.clone()).await.unwrap();
let draft = test_event_draft("Original Event");
let event = aim.new_event(draft).await.unwrap();
let uid = event.uid().as_ref().to_string();
let file_path = temp_dirs.calendar_path.join(format!("{uid}.ics"));
let retrieved1 = aim.get_event(&Id::Uid(uid.clone())).await.unwrap();
assert_eq!(retrieved1.summary().as_ref(), "Original Event");
let modified_content = format!(
r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//AIM//Test//EN
BEGIN:VEVENT
UID:{}
DTSTAMP:20250125T120000Z
DTSTART:20250125T100000Z
DTEND:20250125T110000Z
SUMMARY:Externally Modified
DESCRIPTION:Modified outside AIM
END:VEVENT
END:VCALENDAR
"#,
uid
);
fs::write(&file_path, modified_content).await.unwrap();
aim.close().await.unwrap();
let aim2 = Aim::new(config).await.unwrap();
let events = aim2
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert!(
!events.is_empty(),
"Event should still be present after external modification"
);
}
#[tokio::test]
async fn file_sync_database_rebuild_from_files() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim1 = Aim::new(config.clone()).await.unwrap();
for i in 1..=3 {
let draft = test_event_draft(&format!("Event {i}"));
aim1.new_event(draft).await.unwrap();
}
for i in 1..=3 {
let draft = test_todo_draft(&format!("Todo {i}"));
aim1.new_todo(draft).await.unwrap();
}
let event_count_before = aim1
.count_events(&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
})
.await
.unwrap();
let todo_count_before = aim1
.count_todos(&TodoConditions {
calendar_id: None,
status: None,
due: None,
})
.await
.unwrap();
assert_eq!(event_count_before, 3);
assert_eq!(todo_count_before, 3);
aim1.close().await.unwrap();
let aim2 = Aim::new(config).await.unwrap();
let event_count_after = aim2
.count_events(&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
})
.await
.unwrap();
let todo_count_after = aim2
.count_todos(&TodoConditions {
calendar_id: None,
status: None,
due: None,
})
.await
.unwrap();
assert_eq!(event_count_after, 3);
assert_eq!(todo_count_after, 3);
let events = aim2
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(events.len(), 3);
let todos = aim2
.list_todos(
&TodoConditions {
calendar_id: None,
status: None,
due: None,
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(todos.len(), 3);
}
#[tokio::test]
async fn file_sync_add_remove_calendar_files() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config.clone()).await.unwrap();
let events1 = aim
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert!(events1.is_empty());
let uid = "external-event-123";
let ics_content = format!(
r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//AIM//Test//EN
BEGIN:VEVENT
UID:{}
DTSTAMP:20250125T120000Z
DTSTART:20250125T100000Z
DTEND:20250125T110000Z
SUMMARY:External Event
END:VEVENT
END:VCALENDAR
"#,
uid
);
temp_dirs.create_ics_file(uid, &ics_content).await.unwrap();
aim.close().await.unwrap();
let aim2 = Aim::new(config.clone()).await.unwrap();
let events2 = aim2
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let file_path = temp_dirs.calendar_path.join(format!("{uid}.ics"));
assert!(file_path.exists(), "External file should exist");
if !events2.is_empty() {
assert_eq!(events2[0].uid().as_ref(), uid);
}
let file_path = temp_dirs.calendar_path.join(format!("{uid}.ics"));
fs::remove_file(&file_path).await.unwrap();
aim2.close().await.unwrap();
let aim3 = Aim::new(config).await.unwrap();
let events3 = aim3
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(
events3.len(),
0,
"Removed file should not appear after reload"
);
}
#[tokio::test]
async fn file_sync_corrupted_file_handling() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
for i in 1..=3 {
let uid = format!("valid-event-{i}");
let ics_content = format!(
r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//AIM//Test//EN
BEGIN:VEVENT
UID:{}
DTSTAMP:20250125T120000Z
DTSTART:20250125T100000Z
DTEND:20250125T110000Z
SUMMARY:Valid Event {}
END:VEVENT
END:VCALENDAR
"#,
uid, i
);
temp_dirs.create_ics_file(&uid, &ics_content).await.unwrap();
}
let corrupted_uid = "corrupted-event";
let corrupted_content = "INVALID:ICS:CONTENT\r\nNOT:PROPER:FORMAT\r\n";
temp_dirs
.create_ics_file(corrupted_uid, corrupted_content)
.await
.unwrap();
let aim = Aim::new(config).await.unwrap();
let events = aim
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
if !events.is_empty() {
let summaries: Vec<_> = events
.iter()
.map(|e| e.summary().as_ref().to_string())
.collect();
assert!(
summaries.contains(&"Valid Event 1".to_string())
|| summaries.contains(&"Valid Event 2".to_string())
|| summaries.contains(&"Valid Event 3".to_string()),
"At least one valid event should be loaded"
);
}
}
#[tokio::test]
async fn file_sync_mixed_components_in_single_file() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let uid = "mixed-calendar-file";
let ics_content = r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//AIM//Test//EN
BEGIN:VEVENT
UID:mixed-event-1
DTSTAMP:20250125T120000Z
DTSTART:20250125T100000Z
DTEND:20250125T110000Z
SUMMARY:Event in mixed file
END:VEVENT
BEGIN:VTODO
UID:mixed-todo-1
DTSTAMP:20250125T120000Z
DUE:20250126T100000Z
SUMMARY:Todo in mixed file
STATUS:NEEDS-ACTION
END:VTODO
BEGIN:VEVENT
UID:mixed-event-2
DTSTAMP:20250125T120000Z
DTSTART:20250125T140000Z
DTEND:20250125T150000Z
SUMMARY:Second event in mixed file
END:VEVENT
END:VCALENDAR
"#;
temp_dirs.create_ics_file(uid, ics_content).await.unwrap();
let aim = Aim::new(config).await.unwrap();
let events = aim
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let todos = aim
.list_todos(
&TodoConditions {
calendar_id: None,
status: None,
due: None,
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let file_path = temp_dirs.calendar_path.join(format!("{uid}.ics"));
assert!(file_path.exists(), "Mixed component file should exist");
if events.len() >= 2 {
let event_uids: Vec<_> = events
.iter()
.map(|e| e.uid().as_ref().to_string())
.collect();
assert!(event_uids.contains(&"mixed-event-1".to_string()));
assert!(event_uids.contains(&"mixed-event-2".to_string()));
}
if !todos.is_empty() {
assert_eq!(todos[0].uid().as_ref(), "mixed-todo-1");
}
}
#[tokio::test]
async fn file_sync_empty_directory_handling() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let events = aim
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(events.len(), 0);
let todos = aim
.list_todos(
&TodoConditions {
calendar_id: None,
status: None,
due: None,
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(todos.len(), 0);
}
#[tokio::test]
async fn file_sync_non_ics_files_ignored() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let uid = "valid-event";
let ics_content = r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//AIM//Test//EN
BEGIN:VEVENT
UID:valid-event
DTSTAMP:20250125T120000Z
DTSTART:20250125T100000Z
DTEND:20250125T110000Z
SUMMARY:Valid Event
END:VEVENT
END:VCALENDAR
"#;
temp_dirs.create_ics_file(uid, ics_content).await.unwrap();
let text_path = temp_dirs.calendar_path.join("readme.txt");
fs::write(&text_path, "This is a text file").await.unwrap();
let json_path = temp_dirs.calendar_path.join("data.json");
fs::write(&json_path, r#"{"key": "value"}"#).await.unwrap();
let md_path = temp_dirs.calendar_path.join("notes.md");
fs::write(&md_path, "# Notes").await.unwrap();
let aim = Aim::new(config).await.unwrap();
let events = aim
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let ics_path = temp_dirs.calendar_path.join("valid-event.ics");
assert!(ics_path.exists(), "Valid .ics file should exist");
assert!(temp_dirs.calendar_path.join("readme.txt").exists());
assert!(temp_dirs.calendar_path.join("data.json").exists());
assert!(temp_dirs.calendar_path.join("notes.md").exists());
if !events.is_empty() {
assert_eq!(events[0].uid().as_ref(), "valid-event");
}
}
#[tokio::test]
async fn file_sync_persistence_across_restarts() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim1 = Aim::new(config.clone()).await.unwrap();
let event_draft = test_event_draft("Persistent Event");
let event1 = aim1.new_event(event_draft).await.unwrap();
let event_uid = event1.uid().as_ref().to_string();
let todo_draft = TodoDraft {
due: Some(LooseDateTime::Local(Zoned::now() + Span::new().days(1))),
..test_todo_draft("Persistent Todo")
};
let todo1 = aim1.new_todo(todo_draft).await.unwrap();
let todo_uid = todo1.uid().as_ref().to_string();
aim1.close().await.unwrap();
let aim2 = Aim::new(config.clone()).await.unwrap();
let event2 = aim2.get_event(&Id::Uid(event_uid.clone())).await.unwrap();
assert_eq!(event2.uid().as_ref(), event_uid);
assert_eq!(event2.summary().as_ref(), "Persistent Event");
let todo2 = aim2.get_todo(&Id::Uid(todo_uid.clone())).await.unwrap();
assert_eq!(todo2.uid().as_ref(), todo_uid);
assert_eq!(todo2.summary().as_ref(), "Persistent Todo");
aim2.close().await.unwrap();
let aim3 = Aim::new(config).await.unwrap();
let events = aim3
.list_events(
&EventConditions {
calendar_id: None,
startable: None,
cutoff: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(events.len(), 1);
let todos = aim3
.list_todos(
&TodoConditions {
calendar_id: None,
status: None,
due: None,
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(todos.len(), 1);
}