1use aimcal_caldav::{CalDavClient, CalDavConfig, CalendarQueryRequest, ETag, Href};
8use aimcal_ical::{ICalendar, VEvent, VTodo, semantic::CalendarComponent};
9use async_trait::async_trait;
10use jiff::Zoned;
11use serde::{Deserialize, Serialize};
12use tracing::{error, instrument};
13
14use crate::db::Db;
15use crate::store::{Store, StoreError, SyncResult};
16use crate::{EventPatch, TodoPatch};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CaldavMetadata {
21 pub etag: String,
23 pub last_modified: Option<String>,
25}
26
27#[derive(Debug)]
32pub struct CaldavStore {
33 client: CalDavClient,
35 calendar_href: Href,
37 db: Db,
39 calendar_id: String,
41}
42
43impl CaldavStore {
44 pub fn new(
50 config: CalDavConfig,
51 calendar_href: String,
52 db: Db,
53 calendar_id: String,
54 ) -> Result<Self, StoreError> {
55 let client = CalDavClient::new(config)?;
56
57 Ok(Self {
58 client,
59 calendar_href: Href::new(calendar_href),
60 db,
61 calendar_id,
62 })
63 }
64
65 fn extract_event(calendar: &ICalendar<String>) -> Result<VEvent<String>, StoreError> {
67 for component in &calendar.components {
68 if let CalendarComponent::Event(event) = component {
69 return Ok(event.clone());
70 }
71 }
72 Err("No VEVENT component found in calendar data".into())
73 }
74
75 fn extract_todo(calendar: &ICalendar<String>) -> Result<VTodo<String>, StoreError> {
77 for component in &calendar.components {
78 if let CalendarComponent::Todo(todo) = component {
79 return Ok(todo.clone());
80 }
81 }
82 Err("No VTODO component found in calendar data".into())
83 }
84
85 fn wrap_event(event: &VEvent<String>) -> ICalendar<String> {
87 ICalendar {
88 prod_id: aimcal_ical::ProductId::default(),
89 version: aimcal_ical::Version::default(),
90 calscale: None,
91 method: None,
92 components: vec![CalendarComponent::Event(event.clone())],
93 x_properties: Vec::new(),
94 retained_properties: Vec::new(),
95 }
96 }
97
98 fn wrap_todo(todo: &VTodo<String>) -> ICalendar<String> {
100 ICalendar {
101 prod_id: aimcal_ical::ProductId::default(),
102 version: aimcal_ical::Version::default(),
103 calscale: None,
104 method: None,
105 components: vec![CalendarComponent::Todo(todo.clone())],
106 x_properties: Vec::new(),
107 retained_properties: Vec::new(),
108 }
109 }
110
111 fn generate_href(&self, uid: &str) -> Href {
113 let calendar_path = self.calendar_href.as_str();
114 let path = calendar_path.trim_end_matches('/');
115
116 Href::new(format!("{path}/{uid}.ics"))
117 }
118
119 fn etag_to_string(etag: &ETag) -> String {
121 etag.as_str().to_string()
122 }
123
124 async fn get_resource(
126 &self,
127 uid: &str,
128 ) -> Result<Option<(String, CaldavMetadata)>, StoreError> {
129 let record = self.db.resources.get(uid, &self.calendar_id).await?;
130
131 match record {
132 Some(rec) => {
133 let metadata: CaldavMetadata = rec
134 .metadata_json()
135 .ok_or("Failed to parse CaldavMetadata from JSON")?;
136
137 Ok(Some((rec.resource_id, metadata)))
138 }
139 None => Ok(None),
140 }
141 }
142
143 async fn get_resource_or_none(
149 &self,
150 uid: &str,
151 ) -> Result<Option<(String, CaldavMetadata)>, StoreError> {
152 let record = self.db.resources.get(uid, &self.calendar_id).await?;
153
154 match record {
155 Some(rec) => Ok(rec.metadata_json().map(|m| (rec.resource_id, m))),
156 None => Ok(None),
157 }
158 }
159}
160
161#[async_trait]
162#[allow(clippy::too_many_lines)]
163impl Store for CaldavStore {
164 async fn create_event(&self, uid: &str, event: &VEvent<String>) -> Result<String, StoreError> {
166 let calendar = Self::wrap_event(event);
167 let href = self.generate_href(uid);
168 let etag = self.client.create_event(&href, &calendar).await?;
169
170 let metadata = CaldavMetadata {
171 etag: Self::etag_to_string(&etag),
172 last_modified: None,
173 };
174
175 let metadata_json = serde_json::to_string(&metadata)?;
176 self.db
177 .resources
178 .insert(uid, &self.calendar_id, href.as_str(), Some(&metadata_json))
179 .await?;
180
181 Ok(href.as_str().to_string())
182 }
183
184 async fn get_event(&self, uid: &str) -> Result<VEvent<String>, StoreError> {
186 let (href, _metadata) = self
187 .get_resource(uid)
188 .await?
189 .ok_or(format!("Event not found: {uid}"))?;
190
191 let resource = self.client.get_event(&Href::new(href)).await?;
192 Self::extract_event(&resource.data)
193 }
194
195 async fn update_event(
197 &self,
198 uid: &str,
199 patch: &EventPatch,
200 ) -> Result<VEvent<String>, StoreError> {
201 let (href, metadata) = self
202 .get_resource(uid)
203 .await?
204 .ok_or(format!("Event not found: {uid}"))?;
205
206 let resource = self.client.get_event(&Href::new(href.clone())).await?;
208 let mut event = Self::extract_event(&resource.data)?;
209
210 let now = Zoned::now();
212 let resolved = patch.resolve(now);
213 resolved.apply_to(&mut event);
214
215 let calendar = Self::wrap_event(&event);
217 let etag = ETag::new(metadata.etag.clone());
218 let new_etag = self
219 .client
220 .update_event(&Href::new(href.clone()), &etag, &calendar)
221 .await?;
222
223 let new_metadata = CaldavMetadata {
225 etag: Self::etag_to_string(&new_etag),
226 last_modified: None,
227 };
228
229 let metadata_json = serde_json::to_string(&new_metadata)?;
230 self.db
231 .resources
232 .insert(uid, &self.calendar_id, &href, Some(&metadata_json))
233 .await?;
234
235 Ok(event)
236 }
237
238 async fn delete_event(&self, uid: &str) -> Result<(), StoreError> {
240 let (href, metadata) = self
241 .get_resource(uid)
242 .await?
243 .ok_or(format!("Event not found: {uid}"))?;
244
245 let etag = ETag::new(metadata.etag.clone());
246 self.client
247 .delete_event(&Href::new(href.clone()), &etag)
248 .await?;
249
250 self.db.resources.delete(uid, &self.calendar_id).await?;
251
252 Ok(())
253 }
254
255 async fn create_todo(&self, uid: &str, todo: &VTodo<String>) -> Result<String, StoreError> {
257 let calendar = Self::wrap_todo(todo);
258 let href = self.generate_href(uid);
259 let etag = self.client.create_todo(&href, &calendar).await?;
260
261 let metadata = CaldavMetadata {
262 etag: Self::etag_to_string(&etag),
263 last_modified: None,
264 };
265
266 let metadata_json = serde_json::to_string(&metadata)?;
267 self.db
268 .resources
269 .insert(uid, &self.calendar_id, href.as_str(), Some(&metadata_json))
270 .await?;
271
272 Ok(href.as_str().to_string())
273 }
274
275 async fn get_todo(&self, uid: &str) -> Result<VTodo<String>, StoreError> {
277 let (href, _metadata) = self
278 .get_resource(uid)
279 .await?
280 .ok_or(format!("Todo not found: {uid}"))?;
281
282 let resource = self.client.get_todo(&Href::new(href)).await?;
283 Self::extract_todo(&resource.data)
284 }
285
286 async fn update_todo(&self, uid: &str, patch: &TodoPatch) -> Result<VTodo<String>, StoreError> {
288 let (href, metadata) = self
289 .get_resource(uid)
290 .await?
291 .ok_or(format!("Todo not found: {uid}"))?;
292
293 let resource = self.client.get_todo(&Href::new(href.clone())).await?;
295 let mut todo = Self::extract_todo(&resource.data)?;
296
297 let now = Zoned::now();
299 let resolved = patch.resolve(&now);
300 resolved.apply_to(&mut todo);
301
302 let calendar = Self::wrap_todo(&todo);
304 let etag = ETag::new(metadata.etag.clone());
305 let new_etag = self
306 .client
307 .update_todo(&Href::new(href.clone()), &etag, &calendar)
308 .await?;
309
310 let new_metadata = CaldavMetadata {
312 etag: Self::etag_to_string(&new_etag),
313 last_modified: None,
314 };
315
316 let metadata_json = serde_json::to_string(&new_metadata)?;
317 self.db
318 .resources
319 .insert(uid, &self.calendar_id, &href, Some(&metadata_json))
320 .await?;
321
322 Ok(todo)
323 }
324
325 async fn delete_todo(&self, uid: &str) -> Result<(), StoreError> {
327 let (href, metadata) = self
328 .get_resource(uid)
329 .await?
330 .ok_or(format!("Todo not found: {uid}"))?;
331
332 let etag = ETag::new(metadata.etag.clone());
333 self.client
334 .delete_todo(&Href::new(href.clone()), &etag)
335 .await?;
336
337 self.db.resources.delete(uid, &self.calendar_id).await?;
338
339 Ok(())
340 }
341
342 #[instrument(skip(self))]
343 async fn list_events(&self) -> Result<Vec<(String, VEvent<String>)>, StoreError> {
344 let request = CalendarQueryRequest::new().component("VEVENT".to_string());
345 let resources = self.client.query(&self.calendar_href, &request).await?;
346
347 let mut result = Vec::new();
348 for resource in resources {
349 match Self::extract_event(&resource.data) {
350 Ok(event) => {
351 let _uid = event.uid.content.to_string();
352 let href = resource.href.as_str().to_string();
353 result.push((href.clone(), event));
354
355 }
371 Err(e) => {
372 error!(error = ?e, href = %resource.href.as_str(), "Failed to extract event");
373 }
374 }
375 }
376
377 Ok(result)
378 }
379
380 #[instrument(skip(self))]
381 async fn list_todos(&self) -> Result<Vec<(String, VTodo<String>)>, StoreError> {
382 let request = CalendarQueryRequest::new().component("VTODO".to_string());
383 let resources = self.client.query(&self.calendar_href, &request).await?;
384
385 let mut result = Vec::new();
386 for resource in resources {
387 match Self::extract_todo(&resource.data) {
388 Ok(todo) => {
389 let _uid = todo.uid.content.to_string();
390 let href = resource.href.as_str().to_string();
391 result.push((href.clone(), todo));
392
393 }
409 Err(e) => {
410 error!(error = ?e, href = %resource.href.as_str(), "Failed to extract todo");
411 }
412 }
413 }
414
415 Ok(result)
416 }
417
418 async fn uid_exists(&self, uid: &str) -> Result<bool, StoreError> {
420 Ok(self.get_resource(uid).await?.is_some())
421 }
422
423 fn calendar_id(&self) -> &str {
425 &self.calendar_id
426 }
427
428 async fn sync_cache(&self) -> Result<SyncResult, StoreError> {
430 self.client.discover().await?;
432
433 let mut created = 0;
434 let mut updated = 0;
435 let deleted = 0;
436
437 let event_request = CalendarQueryRequest::new().component("VEVENT".to_string());
439 let event_resources = self
440 .client
441 .query(&self.calendar_href, &event_request)
442 .await?;
443
444 for resource in event_resources {
445 if let Ok(event) = Self::extract_event(&resource.data) {
446 let uid = event.uid.content.to_string();
447 let href = resource.href.as_str().to_string();
448 let etag_str = Self::etag_to_string(&resource.etag);
449
450 if let Some((existing_href, existing_metadata)) =
451 self.get_resource_or_none(&uid).await?
452 {
453 if existing_href == href {
454 if existing_metadata.etag != etag_str {
456 let metadata = CaldavMetadata {
458 etag: etag_str,
459 last_modified: None,
460 };
461 let metadata_json = serde_json::to_string(&metadata)?;
462 self.db
463 .resources
464 .insert(&uid, &self.calendar_id, &href, Some(&metadata_json))
465 .await?;
466 updated += 1;
467 }
468 } else {
469 let metadata = CaldavMetadata {
472 etag: etag_str,
473 last_modified: None,
474 };
475 let metadata_json = serde_json::to_string(&metadata)?;
476 self.db
477 .resources
478 .insert(&uid, &self.calendar_id, &href, Some(&metadata_json))
479 .await?;
480 created += 1;
481 }
482 } else {
483 let metadata = CaldavMetadata {
485 etag: etag_str,
486 last_modified: None,
487 };
488 let metadata_json = serde_json::to_string(&metadata)?;
489 self.db
490 .resources
491 .insert(&uid, &self.calendar_id, &href, Some(&metadata_json))
492 .await?;
493 created += 1;
494 }
495 }
496 }
497
498 let todo_request = CalendarQueryRequest::new().component("VTODO".to_string());
500 let todo_resources = self
501 .client
502 .query(&self.calendar_href, &todo_request)
503 .await?;
504
505 for resource in todo_resources {
506 if let Ok(todo) = Self::extract_todo(&resource.data) {
507 let uid = todo.uid.content.to_string();
508 let href = resource.href.as_str().to_string();
509 let etag_str = Self::etag_to_string(&resource.etag);
510
511 if let Some((existing_href, existing_metadata)) =
512 self.get_resource_or_none(&uid).await?
513 {
514 if existing_href == href {
515 if existing_metadata.etag != etag_str {
517 let metadata = CaldavMetadata {
519 etag: etag_str,
520 last_modified: None,
521 };
522 let metadata_json = serde_json::to_string(&metadata)?;
523 self.db
524 .resources
525 .insert(&uid, &self.calendar_id, &href, Some(&metadata_json))
526 .await?;
527 updated += 1;
528 }
529 } else {
530 let metadata = CaldavMetadata {
532 etag: etag_str,
533 last_modified: None,
534 };
535 let metadata_json = serde_json::to_string(&metadata)?;
536 self.db
537 .resources
538 .insert(&uid, &self.calendar_id, &href, Some(&metadata_json))
539 .await?;
540 created += 1;
541 }
542 } else {
543 let metadata = CaldavMetadata {
545 etag: etag_str,
546 last_modified: None,
547 };
548 let metadata_json = serde_json::to_string(&metadata)?;
549 self.db
550 .resources
551 .insert(&uid, &self.calendar_id, &href, Some(&metadata_json))
552 .await?;
553 created += 1;
554 }
555 }
556 }
557
558 Ok(SyncResult {
563 created,
564 updated,
565 deleted,
566 })
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use aimcal_ical::TodoStatusValue;
574 use aimcal_ical::{Description, DtEnd, DtStamp, DtStart, Summary, Uid};
575 use jiff::{civil, tz};
576 use std::error::Error;
577 use wiremock::matchers::{header, method, path};
578 use wiremock::{Mock, MockServer, ResponseTemplate};
579
580 use crate::db::Db;
581 use aimcal_ical::TodoStatus;
582
583 fn test_vevent() -> VEvent<String> {
584 VEvent {
585 uid: Uid::new("test-event-uid".to_string()),
586 dt_stamp: DtStamp::new(aimcal_ical::DateTimeUtc {
587 date: aimcal_ical::Date::new(2025, 1, 15).unwrap(),
588 time: aimcal_ical::Time::new(10, 0, 0).unwrap(),
589 x_parameters: Vec::new(),
590 retained_parameters: Vec::new(),
591 span: (),
592 }),
593 dt_start: DtStart::new(crate::LooseDateTime::Local(
594 civil::date(2025, 1, 15)
595 .at(10, 0, 0, 0)
596 .to_zoned(tz::TimeZone::UTC)
597 .unwrap(),
598 )),
599 dt_end: Some(DtEnd::new(crate::LooseDateTime::Local(
600 civil::date(2025, 1, 15)
601 .at(11, 0, 0, 0)
602 .to_zoned(tz::TimeZone::UTC)
603 .unwrap(),
604 ))),
605 summary: Some(Summary::new("Test Event".to_string())),
606 description: Some(Description::new("Test Description".to_string())),
607 status: Some(aimcal_ical::semantic::EventStatus::new(
608 aimcal_ical::EventStatusValue::Confirmed,
609 )),
610 duration: None,
611 location: None,
612 geo: None,
613 url: None,
614 organizer: None,
615 attendees: Vec::new(),
616 last_modified: None,
617 transparency: None,
618 sequence: None,
619 priority: None,
620 classification: None,
621 resources: None,
622 categories: None,
623 rrule: None,
624 rdates: Vec::new(),
625 ex_dates: Vec::new(),
626 x_properties: Vec::new(),
627 retained_properties: Vec::new(),
628 alarms: Vec::new(),
629 }
630 }
631
632 fn test_vtodo() -> VTodo<String> {
633 VTodo {
634 uid: Uid::new("test-todo-uid".to_string()),
635 dt_stamp: DtStamp::new(aimcal_ical::DateTimeUtc {
636 date: aimcal_ical::Date::new(2025, 1, 15).unwrap(),
637 time: aimcal_ical::Time::new(10, 0, 0).unwrap(),
638 x_parameters: Vec::new(),
639 retained_parameters: Vec::new(),
640 span: (),
641 }),
642 dt_start: None,
643 due: Some(aimcal_ical::Due::new(crate::LooseDateTime::Local(
644 civil::date(2025, 1, 16)
645 .at(10, 0, 0, 0)
646 .to_zoned(tz::TimeZone::UTC)
647 .unwrap(),
648 ))),
649 completed: None,
650 duration: None,
651 summary: Some(Summary::new("Test Todo".to_string())),
652 description: Some(Description::new("Test Description".to_string())),
653 status: Some(TodoStatus::new(TodoStatusValue::NeedsAction)),
654 percent_complete: Some(aimcal_ical::PercentComplete::new(0)),
655 priority: Some(aimcal_ical::Priority::new(5)),
656 location: None,
657 geo: None,
658 url: None,
659 organizer: None,
660 attendees: Vec::new(),
661 last_modified: None,
662 sequence: None,
663 classification: None,
664 resources: None,
665 categories: None,
666 rrule: None,
667 rdates: Vec::new(),
668 ex_dates: Vec::new(),
669 x_properties: Vec::new(),
670 retained_properties: Vec::new(),
671 alarms: Vec::new(),
672 }
673 }
674
675 #[test]
676 fn extract_event_returns_event_from_calendar() {
677 let event = test_vevent();
678 let calendar = CaldavStore::wrap_event(&event);
679
680 let extracted = CaldavStore::extract_event(&calendar).unwrap();
681 assert_eq!(extracted.uid.content.to_string(), "test-event-uid");
682 assert_eq!(
683 extracted.summary.as_ref().unwrap().content.to_string(),
684 "Test Event"
685 );
686 }
687
688 #[test]
689 fn extract_event_fails_without_event() {
690 let calendar = ICalendar {
691 prod_id: aimcal_ical::ProductId::default(),
692 version: aimcal_ical::Version::default(),
693 calscale: None,
694 method: None,
695 components: vec![CalendarComponent::Todo(test_vtodo())],
696 x_properties: Vec::new(),
697 retained_properties: Vec::new(),
698 };
699
700 assert!(CaldavStore::extract_event(&calendar).is_err());
701 }
702
703 #[test]
704 fn extract_todo_returns_todo_from_calendar() {
705 let todo = test_vtodo();
706 let calendar = CaldavStore::wrap_todo(&todo);
707
708 let extracted = CaldavStore::extract_todo(&calendar).unwrap();
709 assert_eq!(extracted.uid.content.to_string(), "test-todo-uid");
710 assert_eq!(
711 extracted.summary.as_ref().unwrap().content.to_string(),
712 "Test Todo"
713 );
714 }
715
716 #[test]
717 fn extract_todo_fails_without_todo() {
718 let calendar = ICalendar {
719 prod_id: aimcal_ical::ProductId::default(),
720 version: aimcal_ical::Version::default(),
721 calscale: None,
722 method: None,
723 components: vec![CalendarComponent::Event(test_vevent())],
724 x_properties: Vec::new(),
725 retained_properties: Vec::new(),
726 };
727
728 assert!(CaldavStore::extract_todo(&calendar).is_err());
729 }
730
731 #[test]
732 fn generate_href_creates_valid_path() {
733 let calendar_href = "/dav/calendars/user/default/";
735 let path = calendar_href.trim_end_matches('/');
736 let href = format!("{path}/test-uid.ics");
737 assert_eq!(href, "/dav/calendars/user/default/test-uid.ics");
738 }
739
740 #[test]
741 fn generate_href_trims_trailing_slash() {
742 let calendar_href = "/dav/calendars/user/default/";
744 let path = calendar_href.trim_end_matches('/');
745 let href = format!("{path}/test-uid.ics");
746 assert!(!href.contains("//test-uid.ics"));
748 }
749
750 #[test]
751 fn wrap_event_creates_valid_calendar() {
752 let event = test_vevent();
753 let calendar = CaldavStore::wrap_event(&event);
754
755 assert_eq!(calendar.events().len(), 1);
756 assert_eq!(
757 calendar.events().first().unwrap().uid.content.to_string(),
758 "test-event-uid"
759 );
760 assert_eq!(calendar.todos().len(), 0);
761 }
762
763 #[test]
764 fn wrap_todo_creates_valid_calendar() {
765 let todo = test_vtodo();
766 let calendar = CaldavStore::wrap_todo(&todo);
767
768 assert_eq!(calendar.todos().len(), 1);
769 assert_eq!(
770 calendar.todos().first().unwrap().uid.content.to_string(),
771 "test-todo-uid"
772 );
773 assert_eq!(calendar.events().len(), 0);
774 }
775
776 #[test]
777 fn caldav_metadata_serializes_correctly() {
778 let metadata = CaldavMetadata {
779 etag: "abc123".to_string(),
780 last_modified: Some("Wed, 15 Jan 2025 10:00:00 GMT".to_string()),
781 };
782
783 let json = serde_json::to_string(&metadata).unwrap();
784 assert!(json.contains("abc123"));
785 assert!(json.contains("Wed, 15 Jan 2025 10:00:00 GMT"));
786 }
787
788 #[test]
789 fn caldav_metadata_deserializes_correctly() {
790 let json = r#"{"etag":"\"abc123\"","last_modified":"Wed, 15 Jan 2025 10:00:00 GMT"}"#;
791
792 let metadata: CaldavMetadata = serde_json::from_str(json).unwrap();
793 assert_eq!(metadata.etag, "\"abc123\"");
794 assert_eq!(
795 metadata.last_modified,
796 Some("Wed, 15 Jan 2025 10:00:00 GMT".to_string())
797 );
798 }
799
800 #[test]
801 fn calendar_id_returns_default() {
802 assert_eq!("default", "default");
805 }
806
807 #[tokio::test]
808 async fn backend_caldav_new_creates_backend() {
809 let mock_server = MockServer::start().await;
810
811 Mock::given(method("OPTIONS"))
813 .and(path("/dav/calendars/user/"))
814 .respond_with(
815 ResponseTemplate::new(200)
816 .insert_header("DAV", "1, 2, access-control, calendar-access"),
817 )
818 .mount(&mock_server)
819 .await;
820
821 let config = CalDavConfig {
822 base_url: mock_server.uri(),
823 calendar_home: "/dav/calendars/user/".to_string(),
824 auth: aimcal_caldav::AuthMethod::None,
825 ..Default::default()
826 };
827
828 let db = Db::open(None)
829 .await
830 .expect("Failed to create test database");
831
832 let backend = CaldavStore::new(
833 config,
834 "/dav/calendars/user/".to_string(),
835 db,
836 "default".to_string(),
837 )
838 .expect("Failed to create CaldavStore");
839
840 assert_eq!(backend.calendar_id(), "default");
841 assert_eq!(backend.calendar_href.as_str(), "/dav/calendars/user/");
842 }
843
844 #[tokio::test]
845 async fn backend_caldav_etag_to_string() {
846 let etag = ETag::new("\"abc123\"".to_string());
847 let etag_str = CaldavStore::etag_to_string(&etag);
848 assert_eq!(etag_str, "\"abc123\"");
849 }
850
851 #[tokio::test]
852 async fn backend_caldav_extract_event_from_calendar() {
853 let calendar = CaldavStore::wrap_event(&test_vevent());
854
855 let extracted = CaldavStore::extract_event(&calendar).expect("Failed to extract event");
856
857 assert_eq!(extracted.uid.content.to_string(), "test-event-uid");
858 assert_eq!(
859 extracted.summary.as_ref().unwrap().content.to_string(),
860 "Test Event"
861 );
862 }
863
864 #[tokio::test]
865 async fn backend_caldav_extract_todo_from_calendar() {
866 let calendar = CaldavStore::wrap_todo(&test_vtodo());
867
868 let extracted = CaldavStore::extract_todo(&calendar).expect("Failed to extract todo");
869
870 assert_eq!(extracted.uid.content.to_string(), "test-todo-uid");
871 assert_eq!(
872 extracted.summary.as_ref().unwrap().content.to_string(),
873 "Test Todo"
874 );
875 }
876
877 #[tokio::test]
878 async fn backend_caldav_wrap_event_wraps_component() {
879 let event = test_vevent();
880 let calendar = CaldavStore::wrap_event(&event);
881
882 assert_eq!(calendar.events().len(), 1);
883 assert_eq!(
884 calendar.events().first().unwrap().uid.content.to_string(),
885 "test-event-uid"
886 );
887 }
888
889 #[tokio::test]
890 async fn backend_caldav_wrap_todo_wraps_component() {
891 let todo = test_vtodo();
892 let calendar = CaldavStore::wrap_todo(&todo);
893
894 assert_eq!(calendar.todos().len(), 1);
895 assert_eq!(
896 calendar.todos().first().unwrap().uid.content.to_string(),
897 "test-todo-uid"
898 );
899 }
900
901 #[tokio::test]
902 async fn backend_caldav_generate_href_creates_path() {
903 let mock_server = MockServer::start().await;
904
905 Mock::given(method("OPTIONS"))
907 .and(path("/dav/calendars/user/"))
908 .respond_with(ResponseTemplate::new(200).insert_header("DAV", "1, 2, calendar-access"))
909 .mount(&mock_server)
910 .await;
911
912 let config = CalDavConfig {
913 base_url: mock_server.uri(),
914 calendar_home: "/dav/calendars/user/".to_string(),
915 auth: aimcal_caldav::AuthMethod::None,
916 ..Default::default()
917 };
918
919 let db = Db::open(None)
920 .await
921 .expect("Failed to create test database");
922
923 let backend = CaldavStore::new(
924 config,
925 "/dav/calendars/user/default/".to_string(),
926 db,
927 "default".to_string(),
928 )
929 .expect("Failed to create CaldavStore");
930
931 let href = backend.generate_href("test-uid");
932 assert_eq!(href.as_str(), "/dav/calendars/user/default/test-uid.ics");
933 }
934
935 #[tokio::test]
936 async fn backend_caldav_uid_exists_returns_true_for_existing() {
937 let mock_server = MockServer::start().await;
938
939 Mock::given(method("OPTIONS"))
941 .and(path("/dav/calendars/"))
942 .respond_with(ResponseTemplate::new(200).insert_header("DAV", "1, 2, calendar-access"))
943 .mount(&mock_server)
944 .await;
945
946 let config = CalDavConfig {
947 base_url: mock_server.uri(),
948 calendar_home: "/dav/calendars/".to_string(),
949 auth: aimcal_caldav::AuthMethod::None,
950 ..Default::default()
951 };
952
953 let db = Db::open(None)
954 .await
955 .expect("Failed to create test database");
956
957 let backend = CaldavStore::new(
958 config,
959 "/dav/calendars/default/".to_string(),
960 db.clone(),
961 "default".to_string(),
962 )
963 .expect("Failed to create CaldavStore");
964
965 let metadata = CaldavMetadata {
967 etag: "\"abc123\"".to_string(),
968 last_modified: None,
969 };
970 let metadata_json = serde_json::to_string(&metadata).unwrap();
971 db.resources
972 .insert(
973 "existing-uid",
974 "default",
975 "/dav/calendars/default/existing-uid.ics",
976 Some(&metadata_json),
977 )
978 .await
979 .expect("Failed to insert resource");
980
981 let exists = backend
983 .uid_exists("existing-uid")
984 .await
985 .expect("Failed to check uid exists");
986 assert!(exists);
987
988 let not_exists = backend
989 .uid_exists("non-existing-uid")
990 .await
991 .expect("Failed to check uid exists");
992 assert!(!not_exists);
993 }
994
995 #[tokio::test]
996 #[ignore = "Requires mock server with calendar-query support"]
997 async fn backend_caldav_sync_cache_with_new_items() {
998 let mock_server = MockServer::start().await;
999
1000 Mock::given(method("OPTIONS"))
1002 .and(path("/dav/calendars/"))
1003 .respond_with(ResponseTemplate::new(200).insert_header("DAV", "1, 2, calendar-access"))
1004 .mount(&mock_server)
1005 .await;
1006
1007 let event_ical = "\
1009BEGIN:VCALENDAR\r\n\
1010VERSION:2.0\r\n\
1011PRODID:-//Test//Test//EN\r\n\
1012BEGIN:VEVENT\r\n\
1013UID:test-event-1\r\n\
1014DTSTAMP:20250115T100000Z\r\n\
1015DTSTART:20250115T100000Z\r\n\
1016DTEND:20250115T110000Z\r\n\
1017SUMMARY:Test Event 1\r\n\
1018END:VEVENT\r\n\
1019END:VCALENDAR\r\n";
1020
1021 Mock::given(method("REPORT"))
1022 .and(path("/dav/calendars/default/"))
1023 .and(header("Content-Type", "application/xml; charset=utf-8"))
1024 .respond_with(
1025 ResponseTemplate::new(207)
1026 .set_body_raw(
1027 format!(
1028 r#"<?xml version="1.0" encoding="utf-8" ?>
1029<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1030 <D:response>
1031 <D:href>/dav/calendars/default/test-event-1.ics</D:href>
1032 <D:propstat>
1033 <D:prop>
1034 <D:getetag>"new-etag-1"</D:getetag>
1035 <C:calendar-data>{event_ical}</C:calendar-data>
1036 </D:prop>
1037 <D:status>HTTP/1.1 200 OK</D:status>
1038 </D:propstat>
1039 </D:response>
1040</D:multistatus>"#
1041 ),
1042 "application/xml",
1043 )
1044 .insert_header("ETag", "\"new-etag-1\""),
1045 )
1046 .mount(&mock_server)
1047 .await;
1048
1049 let config = CalDavConfig {
1050 base_url: mock_server.uri(),
1051 calendar_home: "/dav/calendars/".to_string(),
1052 auth: aimcal_caldav::AuthMethod::None,
1053 ..Default::default()
1054 };
1055
1056 let db = Db::open(None)
1057 .await
1058 .expect("Failed to create test database");
1059
1060 let backend = CaldavStore::new(
1061 config,
1062 "/dav/calendars/default/".to_string(),
1063 db,
1064 "default".to_string(),
1065 )
1066 .expect("Failed to create CaldavStore");
1067
1068 let result = backend.sync_cache().await.expect("Failed to sync cache");
1069
1070 assert_eq!(result.created, 1);
1071 assert_eq!(result.updated, 0);
1072 assert_eq!(result.deleted, 0);
1073 }
1074
1075 #[tokio::test]
1076 #[ignore = "Requires mock server with calendar-query support"]
1077 async fn backend_caldav_sync_cache_with_updated_items() {
1078 let mock_server = MockServer::start().await;
1079
1080 Mock::given(method("OPTIONS"))
1082 .and(path("/dav/calendars/"))
1083 .respond_with(ResponseTemplate::new(200).insert_header("DAV", "1, 2, calendar-access"))
1084 .mount(&mock_server)
1085 .await;
1086
1087 let event_ical = "\
1089BEGIN:VCALENDAR\r\n\
1090VERSION:2.0\r\n\
1091PRODID:-//Test//Test//EN\r\n\
1092BEGIN:VEVENT\r\n\
1093UID:test-event-2\r\n\
1094DTSTAMP:20250115T100000Z\r\n\
1095DTSTART:20250115T100000Z\r\n\
1096DTEND:20250115T110000Z\r\n\
1097SUMMARY:Updated Event\r\n\
1098END:VEVENT\r\n\
1099END:VCALENDAR\r\n";
1100
1101 Mock::given(method("REPORT"))
1102 .and(path("/dav/calendars/default/"))
1103 .and(header("Content-Type", "application/xml; charset=utf-8"))
1104 .respond_with(
1105 ResponseTemplate::new(207)
1106 .set_body_raw(
1107 format!(
1108 r#"<?xml version="1.0" encoding="utf-8" ?>
1109<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1110 <D:response>
1111 <D:href>/dav/calendars/default/test-event-2.ics</D:href>
1112 <D:propstat>
1113 <D:prop>
1114 <D:getetag>"updated-etag"</D:getetag>
1115 <C:calendar-data>{event_ical}</C:calendar-data>
1116 </D:prop>
1117 <D:status>HTTP/1.1 200 OK</D:status>
1118 </D:propstat>
1119 </D:response>
1120</D:multistatus>"#
1121 ),
1122 "application/xml",
1123 )
1124 .insert_header("ETag", "\"updated-etag\""),
1125 )
1126 .mount(&mock_server)
1127 .await;
1128
1129 let config = CalDavConfig {
1130 base_url: mock_server.uri(),
1131 calendar_home: "/dav/calendars/".to_string(),
1132 auth: aimcal_caldav::AuthMethod::None,
1133 ..Default::default()
1134 };
1135
1136 let db = Db::open(None)
1137 .await
1138 .expect("Failed to create test database");
1139
1140 let backend = CaldavStore::new(
1141 config,
1142 "/dav/calendars/default/".to_string(),
1143 db.clone(),
1144 "default".to_string(),
1145 )
1146 .expect("Failed to create CaldavStore");
1147
1148 let metadata = CaldavMetadata {
1150 etag: "\"old-etag\"".to_string(),
1151 last_modified: None,
1152 };
1153 let metadata_json = serde_json::to_string(&metadata).unwrap();
1154 db.resources
1155 .insert(
1156 "test-event-2",
1157 "default",
1158 "/dav/calendars/default/test-event-2.ics",
1159 Some(&metadata_json),
1160 )
1161 .await
1162 .expect("Failed to insert resource");
1163
1164 let result = backend.sync_cache().await.expect("Failed to sync cache");
1165
1166 assert_eq!(result.created, 0);
1167 assert_eq!(result.updated, 1);
1168 assert_eq!(result.deleted, 0);
1169 }
1170
1171 #[test]
1172 fn backend_caldav_error_from_caldav_http() {
1173 let error: Box<dyn Error> =
1174 aimcal_caldav::CalDavError::Http("HTTP error occurred".to_string()).into();
1175
1176 let error_msg = error.to_string();
1177 assert!(error_msg.contains("HTTP error"));
1178 }
1179
1180 #[test]
1181 fn backend_caldav_error_from_caldav_auth() {
1182 let error: Box<dyn Error> =
1183 aimcal_caldav::CalDavError::Auth("Authentication failed".to_string()).into();
1184
1185 let error_msg = error.to_string();
1186 assert!(error_msg.contains("Authentication failed"));
1187 }
1188
1189 #[test]
1190 fn backend_caldav_error_from_caldav_not_found() {
1191 let error: Box<dyn Error> =
1192 aimcal_caldav::CalDavError::NotFound(Href::new("/test/event.ics".to_string())).into();
1193
1194 let error_msg = error.to_string();
1195 assert!(error_msg.contains("Resource not found"));
1196 assert!(error_msg.contains("/test/event.ics"));
1197 }
1198
1199 #[test]
1200 fn backend_caldav_error_from_caldav_precondition_failed() {
1201 let error: Box<dyn Error> =
1202 aimcal_caldav::CalDavError::PreconditionFailed("ETag mismatch".to_string()).into();
1203
1204 let error_msg = error.to_string();
1205 assert!(error_msg.contains("Precondition failed"));
1206 assert!(error_msg.contains("ETag mismatch"));
1207 }
1208}