#[cfg(feature = "jmap_test")]
mod jmap_tests {
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use calcard::icalendar::{ICalendar, ICalendarProperty, ICalendarValue};
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use libdav::CalDavClient;
use libdav::dav::WebDavClient;
use libjmap::calendar::Calendar;
use rand::{Rng as _, distr::Alphanumeric, rng};
use tokio::sync::{Mutex, MutexGuard};
use tokio::time::sleep;
use tower_http::auth::AddAuthorization;
use vstorage::ItemKind;
use vstorage::base::{CreateItemOptions, Storage};
use vstorage::caldav::{CalDavStorage, CollectionIdSegment};
use vstorage::jmap::JmapStorage;
const BASE_URL: &str = "http://localhost:8080";
const CARDDAV_URL: &str = "http://localhost:8080/dav/calendars/";
const TEST_USERNAME: &str = "user1";
const TEST_PASSWORD: &str = "";
fn random_string(len: usize) -> String {
rng()
.sample_iter(Alphanumeric)
.take(len)
.map(char::from)
.collect()
}
fn minimal_calendar_event(summary: &str) -> String {
let uid = random_string(12);
format!(
concat!(
"BEGIN:VEVENT\r\n",
"UID:{}\r\n",
"DTSTAMP:19970610T172345Z\r\n",
"DTSTART:19970714T170000Z\r\n",
"SUMMARY:{}\r\n",
"END:VEVENT\r\n",
),
uid, summary
)
}
fn update_calendar_event_summary(
original_event: &str,
new_summary: &str,
) -> Result<String, String> {
let mut parsed = ICalendar::parse(original_event)
.map_err(|e| format!("Failed to parse calendar: {:?}", e))?;
for component in &mut parsed.components {
if let Some(summary_entry) = component
.entries
.iter_mut()
.find(|entry| matches!(entry.name, ICalendarProperty::Summary))
{
if let Some(value) = summary_entry.values.first_mut() {
*value = ICalendarValue::Text(new_summary.to_string());
}
}
}
Ok(parsed.to_string())
}
async fn create_jmap_storage() -> Arc<dyn Storage> {
let (storage, _) = create_jmap_storage_with_fresh_collection().await;
storage
}
async fn create_jmap_storage_with_fresh_collection() -> (Arc<dyn Storage>, String) {
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.unwrap()
.https_or_http()
.enable_http1()
.build();
let http_client = Client::builder(TokioExecutor::new()).build(connector);
let mut auth_client = AddAuthorization::basic(http_client, TEST_USERNAME, TEST_PASSWORD);
let base_url = BASE_URL.parse().unwrap();
let (session_uri, session) =
libjmap::discover_session_resource(&mut auth_client, &base_url)
.await
.unwrap();
let api_url = session.api_url().unwrap().parse().unwrap();
let jmap_client = libjmap::JmapClient::new(auth_client, session_uri, api_url);
let collection_name = format!("test-{}", random_string(8));
let created = jmap_client
.create_collection::<Calendar>(&collection_name)
.await
.unwrap();
let storage = JmapStorage::builder(jmap_client).build(ItemKind::Calendar);
(Arc::new(storage), created.id)
}
async fn create_carddav_storage() -> Arc<dyn Storage> {
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.unwrap()
.https_or_http()
.enable_http1()
.build();
let http_client = Client::builder(TokioExecutor::new()).build(connector);
let auth_client = AddAuthorization::basic(http_client, TEST_USERNAME, TEST_PASSWORD);
let webdav_client = WebDavClient::new(CARDDAV_URL.parse().unwrap(), auth_client);
let carddav_client = CalDavClient::new(webdav_client);
let storage = CalDavStorage::builder(carddav_client)
.with_collection_id_segment(CollectionIdSegment::default())
.build()
.await
.unwrap();
Arc::new(storage)
}
async fn lock_storage_write() -> MutexGuard<'static, ()> {
static STORAGE: OnceLock<Mutex<()>> = OnceLock::new();
STORAGE.get_or_init(|| Mutex::new(())).lock().await
}
#[tokio::test]
async fn jmap_connectivity() {
let storage = create_jmap_storage().await;
storage.check().await.unwrap();
assert_eq!(storage.item_kind(), ItemKind::Calendar);
}
#[tokio::test]
async fn jmap_calendar_discovery() {
let storage = create_jmap_storage().await;
let discovery = storage.discover_collections().await.unwrap();
assert!(
!discovery.collections().is_empty(),
"No calendar collections discovered"
);
for collection in discovery.collections() {
println!(
"Found calendar: {} ({})",
collection.href(),
collection.id()
);
}
}
#[tokio::test]
async fn jmap_event_lifecycle() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let discovery = storage.discover_collections().await.unwrap();
let collection = &discovery.collections()[0];
let collection_href = collection.href();
let raw_event = minimal_calendar_event("Test Event");
let item = raw_event.clone().into();
let item_version = storage
.create_item(collection_href, &item, CreateItemOptions::default())
.await
.unwrap();
assert!(item_version.href.starts_with(collection_href));
let (retrieved_item, etag) = storage.get_item(&item_version.href).await.unwrap();
assert_eq!(etag, item_version.etag);
let original_event = ICalendar::parse(&raw_event).unwrap();
let retrieved_event = ICalendar::parse(retrieved_item.as_str()).unwrap();
let original_summary = original_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Summary))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
let retrieved_summary = retrieved_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Summary))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
assert_eq!(original_summary, retrieved_summary);
let updated_event =
update_calendar_event_summary(retrieved_item.as_str(), "Updated Test Event")
.expect("Failed to update calendar event summary");
let updated_item = updated_event.into();
let new_etag = storage
.update_item(&item_version.href, &item_version.etag, &updated_item)
.await
.unwrap();
assert_ne!(new_etag, item_version.etag);
let (updated_retrieved_item, _) = storage.get_item(&item_version.href).await.unwrap();
let updated_retrieved_event = ICalendar::parse(updated_retrieved_item.as_str()).unwrap();
let updated_fn = updated_retrieved_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Summary))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
assert_eq!(updated_fn, "Updated Test Event");
let original_uid = retrieved_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Uid))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
let updated_uid = updated_retrieved_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Uid))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
assert_eq!(
original_uid, updated_uid,
"UID should be preserved during update"
);
storage
.delete_item(&item_version.href, &new_etag)
.await
.unwrap();
let result = storage.get_item(&item_version.href).await;
assert!(result.is_err(), "Item should be deleted");
drop(lock);
}
#[tokio::test]
async fn cross_protocol_calendar_compatibility() {
let lock = lock_storage_write().await;
let jmap_storage = create_jmap_storage().await;
let carddav_storage = create_carddav_storage().await;
let jmap_discovery = jmap_storage.discover_collections().await.unwrap();
let carddav_discovery = carddav_storage.discover_collections().await.unwrap();
assert!(
!jmap_discovery.collections().is_empty(),
"No JMAP collections found"
);
assert!(
!carddav_discovery.collections().is_empty(),
"No CalDAV collections found"
);
let jmap_collection = &jmap_discovery.collections()[0];
let carddav_collection = carddav_discovery
.collections()
.iter()
.find(|c| c.id() == jmap_collection.id())
.unwrap();
let test_event_title = format!("Cross-Protocol Test {}", random_string(12));
let test_event = minimal_calendar_event(&test_event_title);
let item = test_event.clone().into();
let jmap_item = jmap_storage
.create_item(jmap_collection.href(), &item, CreateItemOptions::default())
.await
.unwrap();
sleep(Duration::from_millis(500)).await;
let carddav_items = carddav_storage
.list_items(carddav_collection.href())
.await
.unwrap();
let mut found_via_carddav = false;
for item_version in &carddav_items {
let (carddav_item, _) = carddav_storage.get_item(&item_version.href).await.unwrap();
if carddav_item.as_str().contains(&test_event_title) {
found_via_carddav = true;
println!("Found test event via CalDAV: {}", item_version.href);
let jmap_event = ICalendar::parse(&test_event).unwrap();
let carddav_event = ICalendar::parse(carddav_item.as_str()).unwrap();
let jmap_fn = jmap_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Summary))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
let carddav_fn = carddav_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Summary))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
assert_eq!(
jmap_fn, carddav_fn,
"Event title should match between JMAP and CalDAV"
);
break;
}
}
assert!(
found_via_carddav,
"Event created via JMAP was not found via CalDAV"
);
jmap_storage
.delete_item(&jmap_item.href, &jmap_item.etag)
.await
.unwrap();
sleep(Duration::from_millis(500)).await;
let updated_carddav_items = carddav_storage
.list_items(carddav_collection.href())
.await
.unwrap();
let still_exists_in_carddav = updated_carddav_items.iter().any(|item_version| {
item_version.href.contains(&test_event_title)
});
assert!(
!still_exists_in_carddav,
"Event should be deleted from CalDAV as well"
);
drop(lock);
}
#[tokio::test]
async fn calendar_event_conversion() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let test_cases = vec![
("Simple Event", minimal_calendar_event("Simple Event")),
(
"Event with Description",
minimal_calendar_event("Meeting Event"),
),
(
"Event with Location",
minimal_calendar_event("Conference Event"),
),
];
let discovery = storage.discover_collections().await.unwrap();
let collection = &discovery.collections()[0];
for (name, event_data) in test_cases {
println!("Testing event conversion for: {}", name);
let item = event_data.to_string().into();
let item_version = storage
.create_item(collection.href(), &item, CreateItemOptions::default())
.await
.unwrap();
let (retrieved_item, _) = storage.get_item(&item_version.href).await.unwrap();
let original_event = ICalendar::parse(&event_data).unwrap();
let retrieved_event = ICalendar::parse(retrieved_item.as_str()).unwrap();
let original_fn = original_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Summary))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
let retrieved_summary = retrieved_event
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::Summary))
.and_then(|e| e.values.first())
.and_then(|v| v.as_text())
.unwrap();
assert_eq!(
original_fn, retrieved_summary,
"Event title should be preserved for {}",
name
);
storage
.delete_item(&item_version.href, &item_version.etag)
.await
.unwrap();
}
drop(lock);
}
#[tokio::test]
async fn error_handling() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let result = storage.get_item("nonexistent/item").await;
assert!(
result.is_err(),
"Should fail when getting non-existent item"
);
let discovery = storage.discover_collections().await.unwrap();
let collection = &discovery.collections()[0];
let invalid_event = "INVALID EVENT DATA".to_string().into();
let result = storage
.create_item(
collection.href(),
&invalid_event,
CreateItemOptions::default(),
)
.await;
assert!(
result.is_err(),
"Should fail when creating item with invalid event data"
);
let test_event = minimal_calendar_event("Test Event");
let item = test_event.into();
let result = storage
.create_item(
"nonexistent-collection",
&item,
CreateItemOptions::default(),
)
.await;
assert!(
result.is_err(),
"Should fail when creating item in non-existent collection"
);
drop(lock);
}
#[tokio::test]
async fn batch_operations() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let discovery = storage.discover_collections().await.unwrap();
let collection = &discovery.collections()[0];
let mut item_versions = Vec::new();
for i in 0..5 {
let event_title = format!("Batch Event {}", i);
let event = minimal_calendar_event(&event_title);
let item = event.into();
let item_version = storage
.create_item(collection.href(), &item, CreateItemOptions::default())
.await
.unwrap();
item_versions.push(item_version);
}
let hrefs: Vec<&str> = item_versions.iter().map(|iv| iv.href.as_str()).collect();
let fetched_items = storage.get_many_items(&hrefs).await.unwrap();
assert_eq!(fetched_items.len(), 5, "Should fetch all 5 items");
for num in 0..5 {
let mut found = false;
let event_title = format!("Batch Event {}", num);
for fetched_item in fetched_items.iter() {
if fetched_item.item.as_str().contains(&event_title) {
found = true;
break;
}
}
assert!(found, "Expected to find item with title '{}'.", event_title);
}
for item_version in item_versions {
storage
.delete_item(&item_version.href, &item_version.etag)
.await
.unwrap();
}
drop(lock);
}
#[tokio::test]
async fn status_as_etag() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let discovery = storage.discover_collections().await.unwrap();
let collection_href = discovery.collections()[0].href();
let raw_event1 = minimal_calendar_event("Test Event");
let item1 = raw_event1.clone().into();
let version1 = storage
.create_item(collection_href, &item1, CreateItemOptions::default())
.await
.unwrap();
let raw_event2 = minimal_calendar_event("Test Event");
let item2 = raw_event2.clone().into();
let version2 = storage
.create_item(collection_href, &item2, CreateItemOptions::default())
.await
.unwrap();
assert_ne!(version1.etag, version2.etag);
let _new_version1 = storage
.update_item(&version1.href, &version1.etag, &item1)
.await
.unwrap();
drop(lock);
}
#[tokio::test]
async fn delete_with_stale_etag() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let discovery = storage.discover_collections().await.unwrap();
let collection_href = discovery.collections()[0].href();
let raw_event1 = minimal_calendar_event("Event to Delete");
let item1 = raw_event1.clone().into();
let version1 = storage
.create_item(collection_href, &item1, CreateItemOptions::default())
.await
.unwrap();
let raw_event2 = minimal_calendar_event("Unrelated Event");
let item2 = raw_event2.clone().into();
let _version2 = storage
.create_item(collection_href, &item2, CreateItemOptions::default())
.await
.unwrap();
storage
.delete_item(&version1.href, &version1.etag)
.await
.unwrap();
let result = storage.get_item(&version1.href).await;
assert!(result.is_err(), "Item should be deleted");
drop(lock);
}
#[tokio::test]
async fn delete_with_modified_item() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let discovery = storage.discover_collections().await.unwrap();
let collection_href = discovery.collections()[0].href();
let raw_event = minimal_calendar_event("Event to Modify");
let item = raw_event.clone().into();
let version = storage
.create_item(collection_href, &item, CreateItemOptions::default())
.await
.unwrap();
let modified_event = update_calendar_event_summary(&raw_event, "Modified Event").unwrap();
let modified_item = modified_event.into();
let _new_version = storage
.update_item(&version.href, &version.etag, &modified_item)
.await
.unwrap();
let result = storage.delete_item(&version.href, &version.etag).await;
assert!(
result.is_err(),
"Delete with stale etag should fail when item was modified"
);
drop(lock);
}
#[tokio::test]
async fn changed_since_bootstrap_empty() {
let lock = lock_storage_write().await;
let (storage, collection_id) = create_jmap_storage_with_fresh_collection().await;
let changes = storage.changed_since(&collection_id, None).await.unwrap();
assert!(changes.changed.is_empty());
assert!(changes.deleted.is_empty());
drop(lock);
}
#[tokio::test]
async fn changed_since_incremental_sync() {
let lock = lock_storage_write().await;
let (storage, collection_id) = create_jmap_storage_with_fresh_collection().await;
let collection_href = &collection_id;
let initial_changes = storage.changed_since(collection_href, None).await.unwrap();
let initial_state = initial_changes.new_state.clone();
assert!(initial_changes.changed.is_empty());
assert!(initial_changes.deleted.is_empty());
let raw_event = minimal_calendar_event("Changed Since Test Event");
let item = raw_event.clone().into();
let version = storage
.create_item(collection_href, &item, CreateItemOptions::default())
.await
.unwrap();
let changes = storage
.changed_since(collection_href, initial_state.as_deref())
.await
.unwrap();
assert!(changes.changed.contains(&version.href));
assert!(changes.deleted.is_empty());
let state_after_create = changes.new_state.clone();
let updated_event =
update_calendar_event_summary(&raw_event, "Updated Changed Since Event").unwrap();
let updated_item = updated_event.into();
let new_version = storage
.update_item(&version.href, &version.etag, &updated_item)
.await
.unwrap();
let changes_after_update = storage
.changed_since(collection_href, state_after_create.as_deref())
.await
.unwrap();
assert!(changes_after_update.changed.contains(&version.href));
assert!(changes.deleted.is_empty());
let state_after_update = changes_after_update.new_state.clone();
storage
.delete_item(&version.href, &new_version)
.await
.unwrap();
let changes_after_delete = storage
.changed_since(collection_href, state_after_update.as_deref())
.await
.unwrap();
assert!(changes_after_delete.deleted.contains(&version.href));
drop(lock);
}
#[tokio::test]
async fn changed_since_bootstrap_non_empty() {
let lock = lock_storage_write().await;
let storage = create_jmap_storage().await;
let discovery = storage.discover_collections().await.unwrap();
let collection_href = discovery.collections()[0].href();
let raw_event = minimal_calendar_event("Bootstrap Non-Empty Test Event");
let item = raw_event.into();
let version = storage
.create_item(collection_href, &item, CreateItemOptions::default())
.await
.unwrap();
let changes = storage.changed_since(collection_href, None).await.unwrap();
assert!(changes.changed.contains(&version.href));
assert!(changes.deleted.is_empty());
storage
.delete_item(&version.href, &version.etag)
.await
.unwrap();
drop(lock);
}
}