bundle.rs
⎇
Raw
1//! Feeds, export and import splitting.
2
3use pimdav::bundle::{self, Detail};
4use pimdav::object;
5
6const BERLIN: &str = "BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
7
8fn uids() -> impl FnMut(&str) -> String {
9 let mut n = 0;
10 move |_| {
11 n += 1;
12 format!("new-{n}")
13 }
14}
15
16#[test]
17fn feed_merges_objects_and_deduplicates_zones() {
18 let a = format!(
19 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n{BERLIN}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nSUMMARY:Long\r\n folded\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
20 );
21 // LF endings in storage become CRLF.
22 let b = format!(
23 "BEGIN:VCALENDAR\nVERSION:2.0\n{}BEGIN:VEVENT\nUID:b\nDTSTART;TZID=Europe/Berlin:20240102T100000\nATTENDEE:mailto:x@y\nEND:VEVENT\nBEGIN:VTODO\nUID:c\nEND:VTODO\nEND:VCALENDAR\n",
24 BERLIN.replace("\r\n", "\n")
25 );
26 let full = bundle::calendar(&[&a, &b], Some("Work; home"), Detail::All);
27 assert!(full.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"));
28 assert!(full.contains(r"X-WR-CALNAME:Work\; home"));
29 assert!(full.contains(r"NAME:Work\; home"));
30 assert!(full.contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n"));
31 assert_eq!(full.matches("BEGIN:VTIMEZONE").count(), 1);
32 assert!(full.contains("SUMMARY:Long\r\n folded\r\n"));
33 assert!(full.contains("BEGIN:VTODO\r\nUID:c\r\n"));
34 assert!(!full.replace("\r\n", "").contains('\n'));
35 assert!(full.ends_with("END:VCALENDAR\r\n"));
36
37 let busy = bundle::calendar(&[&a, &b], None, Detail::Busy);
38 assert_eq!(busy.matches("SUMMARY:Busy").count(), 2);
39 assert!(!busy.contains("Long"));
40 assert!(!busy.contains("VALARM"));
41 assert!(!busy.contains("ATTENDEE"));
42 assert!(!busy.contains("VTODO"));
43 assert!(busy.contains("DTSTART;TZID=Europe/Berlin:20240102T100000\r\n"));
44 assert!(busy.contains("BEGIN:VTIMEZONE"));
45}
46
47fn object(components: &str) -> String {
48 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{components}END:VCALENDAR\r\n")
49}
50
51#[test]
52fn busy_time_hides_what_blocks_nothing() {
53 let series = object(
54 "BEGIN:VEVENT\r\nUID:series@corp.example\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\
55 BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240102T100000Z\r\nDTSTART:20240102T100000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n\
56 BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nEND:VEVENT\r\n",
57 );
58 let free = object(
59 "BEGIN:VEVENT\r\nUID:free\r\nDTSTART:20240105T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n",
60 );
61 let busy = bundle::calendar(&[&series, &free], None, Detail::Busy);
62 // The master and the moved instance stay; the cancelled one is excluded.
63 assert_eq!(busy.matches("BEGIN:VEVENT").count(), 2, "{busy}");
64 assert!(busy.contains("EXDATE:20240102T100000Z\r\n"), "{busy}");
65 assert!(!busy.contains("RANGE"));
66 assert!(!busy.contains("corp.example") && !busy.contains("UID:free"));
67 let uids: Vec<&str> = busy.lines().filter(|l| l.starts_with("UID:")).collect();
68 assert_eq!(uids.len(), 2);
69 assert_eq!(uids[0], uids[1], "master and override keep one UID");
70}
71
72#[test]
73fn public_feeds_reduce_private_objects() {
74 let open = object(
75 "BEGIN:VEVENT\r\nUID:open\r\nDTSTART:20240101T100000Z\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n",
76 );
77 let private = object(
78 "BEGIN:VEVENT\r\nUID:doc\r\nDTSTART:20240101T120000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Doctor\r\nCLASS:PRIVATE\r\nEND:VEVENT\r\n\
79 BEGIN:VEVENT\r\nUID:doc\r\nRECURRENCE-ID:20240108T120000Z\r\nDTSTART:20240108T130000Z\r\nSUMMARY:Doctor\r\nEND:VEVENT\r\n",
80 );
81 let task = object(
82 "BEGIN:VTODO\r\nUID:t\r\nSUMMARY:Secret task\r\nCLASS:CONFIDENTIAL\r\nEND:VTODO\r\n",
83 );
84 let public = bundle::calendar(&[&open, &private, &task], None, Detail::Public);
85 assert!(public.contains("SUMMARY:Standup"));
86 assert!(
87 !public.contains("Doctor") && !public.contains("Secret task"),
88 "{public}"
89 );
90 // The whole object is reduced, so its override still matches its master.
91 assert_eq!(public.matches("SUMMARY:Busy").count(), 2);
92 let all = bundle::calendar(&[&open, &private, &task], None, Detail::All);
93 assert!(all.contains("Doctor") && all.contains("Secret task"));
94}
95
96#[test]
97fn import_splits_by_uid_with_overrides_and_zones() {
98 let file = format!(
99 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:src\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:Old\r\n{BERLIN}BEGIN:VTIMEZONE\r\nTZID:Unused\r\nEND:VTIMEZONE\r\n\
100 BEGIN:VEVENT\r\nUID:m\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\
101 BEGIN:VEVENT\r\nUID:plain\r\nDTSTART:20240105T100000Z\r\nEND:VEVENT\r\n\
102 BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID;TZID=Europe/Berlin:20240102T100000\r\nDTSTART;TZID=Europe/Berlin:20240102T120000\r\nEND:VEVENT\r\n\
103 BEGIN:VEVENT\r\nDTSTART:20240106T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
104 );
105 let parts = bundle::split_calendar(&file, &mut uids());
106 assert_eq!(parts.len(), 3);
107 let master = &parts[0];
108 assert_eq!(master.matches("BEGIN:VEVENT").count(), 2);
109 assert!(master.contains("RECURRENCE-ID"));
110 assert!(master.contains("TZID:Europe/Berlin"));
111 assert!(!master.contains("Unused"));
112 assert!(!master.contains("METHOD"));
113 assert!(!master.contains("X-WR-CALNAME"));
114 assert!(master.contains("PRODID:src\r\n"));
115 assert!(!parts[1].contains("VTIMEZONE"));
116 assert!(parts[2].contains("BEGIN:VEVENT\r\nUID:new-1\r\n"));
117 for p in &parts {
118 assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}");
119 }
120 // With a UID from the content, as the server makes it, a file holding
121 // everything twice gives the same objects.
122 let mut by_content = |t: &str| format!("len-{}", t.len());
123 let once = bundle::split_calendar(&file, &mut by_content);
124 let twice = bundle::split_calendar(&format!("{file}{file}"), &mut by_content);
125 assert_eq!(twice, once);
126}
127
128#[test]
129fn escaped_tzid_finds_its_zone() {
130 let file = "BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:(UTC+02:00) Athens\\, Bucharest\r\nEND:VTIMEZONE\r\n\
131 BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=\"(UTC+02:00) Athens, Bucharest\":20130412T150000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
132 let parts = bundle::split_calendar(file, &mut uids());
133 assert!(parts[0].contains("BEGIN:VTIMEZONE"), "{}", parts[0]);
134 assert_eq!(
135 bundle::calendar(&[&parts[0]], None, Detail::All)
136 .matches("BEGIN:VTIMEZONE")
137 .count(),
138 1
139 );
140}
141
142#[test]
143fn import_splits_cards() {
144 let file = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n\
145 BEGIN:VCARD\nVERSION:4.0\nFN:Two\nEND:VCARD\n";
146 let cards = bundle::split_cards(file, &mut uids());
147 assert_eq!(cards.len(), 2);
148 assert_eq!(
149 cards[0],
150 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n"
151 );
152 // Before END: vCard 4.0 wants VERSION right after BEGIN.
153 assert_eq!(
154 cards[1],
155 "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nUID:new-1\r\nEND:VCARD\r\n"
156 );
157 for c in &cards {
158 assert!(object::vcard(c.as_bytes()).unwrap().is_some());
159 }
160 assert_eq!(bundle::cards(&[&cards[0], &cards[1]]), cards.concat());
161}
162
163#[test]
164fn quoted_tzid_keeps_its_zone() {
165 let zone = "BEGIN:VTIMEZONE\r\nTZID:Work\\; late: shift\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
166 let file = format!(
167 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{zone}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=\"Work; late: shift\":20240101T100000\r\nRECURRENCE-ID;TZID=\"Work; late: shift\":20240101T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
168 );
169 let objects = bundle::split_calendar(&file, &mut uids());
170 assert_eq!(objects.len(), 1);
171 assert!(objects[0].contains("TZID:Work\\; late: shift"));
172}
173