Skip to main content

aimcal_core/store/
caldav.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! `CalDAV` store implementation for storing and synchronizing calendar data.
6
7use 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/// Metadata stored with `CalDAV` resources in the database.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CaldavMetadata {
21    /// `ETag` for optimistic concurrency control.
22    pub etag: String,
23    /// Last-Modified header value from server.
24    pub last_modified: Option<String>,
25}
26
27/// `CalDAV` store implementation.
28///
29/// This backend stores calendar data on a `CalDAV` server (RFC 4791),
30/// using the `CalDAV` client for all operations.
31#[derive(Debug)]
32pub struct CaldavStore {
33    /// The `CalDAV` client for server operations.
34    client: CalDavClient,
35    /// The href of the calendar collection on the server.
36    calendar_href: Href,
37    /// The database for local cache.
38    db: Db,
39    /// The calendar identifier in the database.
40    calendar_id: String,
41}
42
43impl CaldavStore {
44    /// Creates a new `CalDAV` backend from configuration.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if client initialization fails.
49    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    /// Extracts a single `VEvent` from an `ICalendar`.
66    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    /// Extracts a single `VTodo` from an `ICalendar`.
76    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    /// Wraps a `VEvent` in an `ICalendar` for transmission.
86    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    /// Wraps a `VTodo` in an `ICalendar` for transmission.
99    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    /// Generates an href for a new resource based on UID.
112    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    /// Converts `ETag` to string for database storage.
120    fn etag_to_string(etag: &ETag) -> String {
121        etag.as_str().to_string()
122    }
123
124    /// Gets the resource record for a UID from the database.
125    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    /// Gets the resource record for a UID from the database.
144    ///
145    /// Unlike [`get_resource`], this returns `None` for records with missing
146    /// or invalid metadata instead of returning an error, treating them as
147    /// new resources during sync.
148    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    // #[instrument]
165    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    // #[instrument]
185    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    // #[instrument]
196    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        // Fetch current event
207        let resource = self.client.get_event(&Href::new(href.clone())).await?;
208        let mut event = Self::extract_event(&resource.data)?;
209
210        // Apply patch
211        let now = Zoned::now();
212        let resolved = patch.resolve(now);
213        resolved.apply_to(&mut event);
214
215        // Upload updated event
216        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        // Update metadata in database
224        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    // #[instrument]
239    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    // #[instrument]
256    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    // #[instrument]
276    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    // #[instrument]
287    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        // Fetch current todo
294        let resource = self.client.get_todo(&Href::new(href.clone())).await?;
295        let mut todo = Self::extract_todo(&resource.data)?;
296
297        // Apply patch
298        let now = Zoned::now();
299        let resolved = patch.resolve(&now);
300        resolved.apply_to(&mut todo);
301
302        // Upload updated todo
303        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        // Update metadata in database
311        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    // #[instrument]
326    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                    // Update metadata in database
356                    // TODO: Re-enable metadata updates after fixing async/await issue
357                    // let metadata = CaldavMetadata {
358                    //     etag: Self::etag_to_string(&resource.etag),
359                    //     last_modified: None,
360                    // };
361                    // let metadata_json = serde_json::to_string(&metadata)?;
362                    // if let Err(e) = self
363                    //     .db
364                    //     .resources
365                    //     .insert(&uid, self.backend_kind, &href, Some(&metadata_json))
366                    //     .await
367                    // {
368                    //         error!(error = ?e, uid, "Failed to update resource metadata");
369                    //     }
370                }
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                    // Update metadata in database
394                    // TODO: Re-enable metadata updates after fixing async/await issue
395                    // let metadata = CaldavMetadata {
396                    //     etag: Self::etag_to_string(&resource.etag),
397                    //     last_modified: None,
398                    // };
399                    // let metadata_json = serde_json::to_string(&metadata)?;
400                    // if let Err(e) = self
401                    //     .db
402                    //     .resources
403                    //     .insert(&uid, self.backend_kind, &href, Some(&metadata_json))
404                    //     .await
405                    // {
406                    //         error!(error = ?e, uid, "Failed to update resource metadata");
407                    //     }
408                }
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    // #[instrument]
419    async fn uid_exists(&self, uid: &str) -> Result<bool, StoreError> {
420        Ok(self.get_resource(uid).await?.is_some())
421    }
422
423    // #[instrument]
424    fn calendar_id(&self) -> &str {
425        &self.calendar_id
426    }
427
428    // #[instrument]
429    async fn sync_cache(&self) -> Result<SyncResult, StoreError> {
430        // Ensure capabilities are discovered before querying
431        self.client.discover().await?;
432
433        let mut created = 0;
434        let mut updated = 0;
435        let deleted = 0;
436
437        // Query all VEVENT resources
438        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                        // Same resource - check if ETag changed
455                        if existing_metadata.etag != etag_str {
456                            // Resource was updated on server
457                            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                        // Different href - this is a new resource with same UID
470                        // In practice, this should not happen with unique UIDs
471                        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                    // New resource
484                    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        // Query all VTODO resources
499        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                        // Same resource - check if ETag changed
516                        if existing_metadata.etag != etag_str {
517                            // Resource was updated on server
518                            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                        // Different href - this is a new resource with same UID
531                        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                    // New resource
544                    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        // Note: We don't handle deletions here because we'd need to track
559        // all known UIDs and compare with what's on the server.
560        // This is a more complex operation that may be added later.
561
562        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        // Test the logic directly without constructing a full backend
734        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        // Test that we don't get double slashes
743        let calendar_href = "/dav/calendars/user/default/";
744        let path = calendar_href.trim_end_matches('/');
745        let href = format!("{path}/test-uid.ics");
746        // Should not have double slash
747        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        // The calendar id for CaldavStore is always "default"
803        // This is verified by integration tests
804        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 OPTIONS request
812        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 OPTIONS request
906        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 OPTIONS request
940        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        // Insert a resource record
966        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        // Test uid_exists
982        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 OPTIONS request
1001        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        // Mock REPORT request for calendar-query (events)
1008        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 OPTIONS request
1081        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        // Mock REPORT request for calendar-query with updated ETag
1088        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        // Insert existing resource with old ETag
1149        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}