api_pim_ui.rs
⎇
Raw
1//! The JSON endpoints behind the calendar and contacts web UI.
2
3mod common;
4
5use axum::http::{Method, StatusCode};
6use common::*;
7use serde_json::{Value, json};
8
9const PW: &str = "secret12345";
10
11struct Ui {
12 env: Env,
13 alice: Client,
14 bob: Client,
15}
16
17impl Ui {
18 async fn new() -> Self {
19 let env = Env::new().await;
20 let admin = env.admin().await;
21 create_user(&admin, "alice", PW, &[("docs", "rw"), ("src", "ro")]).await;
22 create_user(&admin, "bob", PW, &[]).await;
23 let alice = login(&env, "alice", PW).await;
24 let bob = login(&env, "bob", PW).await;
25 Ui { env, alice, bob }
26 }
27
28 async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp {
29 Client::new(self.env.app.clone())
30 .raw(
31 Method::from_bytes(verb.as_bytes()).unwrap(),
32 path,
33 &[("authorization", &basic(user, PW)), ("depth", "1")],
34 body.as_bytes().to_vec(),
35 )
36 .await
37 }
38
39 async fn put(&self, user: &str, path: &str, body: &str) {
40 let r = self.dav(user, "PUT", path, body).await;
41 assert!(
42 r.status.is_success(),
43 "PUT {path}: {} {}",
44 r.status,
45 r.text()
46 );
47 }
48
49 async fn list(&self, who: &Client) -> Vec<Value> {
50 let r = who.get("/api/pim/collections").await;
51 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
52 r.json().as_array().unwrap().clone()
53 }
54
55 async fn id(&self, who: &Client, url: &str) -> i64 {
56 self.list(who)
57 .await
58 .iter()
59 .find(|c| c["url"] == url)
60 .unwrap_or_else(|| panic!("no {url}"))["id"]
61 .as_i64()
62 .unwrap()
63 }
64
65 async fn root_id(&self, index: usize) -> i64 {
66 self.alice.get("/api/auth/me").await.json()["roots"][index]["id"]
67 .as_i64()
68 .unwrap()
69 }
70}
71
72fn ics(body: &str) -> String {
73 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n{body}END:VCALENDAR\r\n")
74}
75
76fn event(uid: &str, props: &str) -> String {
77 ics(&format!(
78 "BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n{props}END:VEVENT\r\n"
79 ))
80}
81
82const ALICE: &str = "mailto:alice@filebrowser.invalid";
83const BOB: &str = "mailto:bob@filebrowser.invalid";
84
85#[tokio::test]
86async fn collections_can_be_created_changed_and_deleted() {
87 let ui = Ui::new().await;
88 let list = ui.list(&ui.alice).await;
89 // Default calendar and address book, plus the generated ones.
90 let generated: Vec<i64> = list
91 .iter()
92 .filter(|c| c["generated"] == true)
93 .map(|c| c["id"].as_i64().unwrap())
94 .collect();
95 assert_eq!(generated, [-1, 0], "{list:?}");
96 let default = list.iter().find(|c| c["is_default"] == true).unwrap();
97 assert_eq!(default["kind"], "calendar");
98
99 let r = ui
100 .alice
101 .post_json(
102 "/api/pim/collections",
103 &json!({"kind": "calendar", "name": "Work & Play", "color": "#ff8800", "components": ["VEVENT"]}),
104 )
105 .await;
106 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
107 let work = r.json();
108 assert_eq!(work["url"], "/pim/calendars/alice/work-play/");
109 assert_eq!(work["components"], json!(["VEVENT"]));
110 let id = work["id"].as_i64().unwrap();
111 // A second one with the same name gets its own URL.
112 let again = ui
113 .alice
114 .post_json(
115 "/api/pim/collections",
116 &json!({"kind": "calendar", "name": "Work & Play"}),
117 )
118 .await;
119 assert_eq!(again.json()["url"], "/pim/calendars/alice/work-play-2/");
120 // Reserved names and bad input.
121 let r = ui
122 .alice
123 .post_json(
124 "/api/pim/collections",
125 &json!({"kind": "addressbook", "name": "System"}),
126 )
127 .await;
128 assert_eq!(r.json()["url"], "/pim/addressbooks/alice/system-2/");
129 for bad in [
130 json!({"kind": "calendar", "name": " "}),
131 json!({"kind": "calendar", "name": "x", "color": "red"}),
132 json!({"kind": "calendar", "name": "x", "components": ["VCARD"]}),
133 ] {
134 let r = ui.alice.post_json("/api/pim/collections", &bad).await;
135 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad}");
136 }
137
138 let r = ui
139 .alice
140 .put_json(
141 &format!("/api/pim/collections/{id}"),
142 &json!({"name": "Work", "color": "", "description": "Job", "transparent": true}),
143 )
144 .await;
145 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
146 let changed = r.json();
147 assert_eq!(changed["name"], "Work");
148 assert_eq!(changed["color"], Value::Null);
149 assert_eq!(changed["description"], "Job");
150 assert_eq!(changed["transparent"], true);
151 // CalDAV sees the same.
152 let r = ui
153 .dav("alice", "PROPFIND", "/pim/calendars/alice/work-play/", "")
154 .await;
155 assert!(r.text().contains("Work</"), "{}", r.text());
156
157 // Bob cannot touch it, even when it is lent to him.
158 let r = ui
159 .alice
160 .post_json(
161 &format!("/api/pim/collections/{id}/shares"),
162 &json!({"user": "bob", "mode": "rw"}),
163 )
164 .await;
165 assert_eq!(r.status, StatusCode::OK);
166 let lent = ui
167 .list(&ui.bob)
168 .await
169 .into_iter()
170 .find(|c| c["id"] == id)
171 .unwrap();
172 assert_eq!(lent["mode"], "rw");
173 assert_eq!(lent["owner"], "alice");
174 assert_eq!(
175 ui.list(&ui.alice)
176 .await
177 .iter()
178 .find(|c| c["id"] == id)
179 .unwrap()["shares"],
180 1
181 );
182 let r = ui
183 .bob
184 .put_json(&format!("/api/pim/collections/{id}"), &json!({"name": "x"}))
185 .await;
186 assert_eq!(r.status, StatusCode::NOT_FOUND);
187 // Bob's delete only ends his loan.
188 assert_eq!(
189 ui.bob
190 .delete(&format!("/api/pim/collections/{id}"))
191 .await
192 .status,
193 StatusCode::OK
194 );
195 assert!(ui.list(&ui.bob).await.iter().all(|c| c["id"] != id));
196 assert!(ui.list(&ui.alice).await.iter().any(|c| c["id"] == id));
197
198 // Generated and default collections stay.
199 for gone in [-1, 0] {
200 let r = ui
201 .alice
202 .delete(&format!("/api/pim/collections/{gone}"))
203 .await;
204 assert_eq!(r.status, StatusCode::FORBIDDEN);
205 }
206 let default_id = default["id"].as_i64().unwrap();
207 let r = ui
208 .alice
209 .delete(&format!("/api/pim/collections/{default_id}"))
210 .await;
211 assert_eq!(r.status, StatusCode::CONFLICT);
212 assert_eq!(r.json()["code"], "err_default_calendar");
213 let r = ui.alice.delete(&format!("/api/pim/collections/{id}")).await;
214 assert_eq!(r.status, StatusCode::OK);
215 assert!(ui.list(&ui.alice).await.iter().all(|c| c["id"] != id));
216}
217
218#[tokio::test]
219async fn instances_expand_readable_calendars() {
220 let ui = Ui::new().await;
221 ui.put(
222 "alice",
223 "/pim/calendars/alice/default/weekly.ics",
224 &(event(
225 "weekly",
226 "DTSTART;TZID=Europe/Berlin:20260105T090000\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;COUNT=3\r\nSUMMARY:Standup\r\nLOCATION:Room 1\r\n",
227 )
228 .replace(
229 "END:VCALENDAR",
230 "BEGIN:VEVENT\r\nUID:weekly\r\nDTSTAMP:20260101T000000Z\r\nRECURRENCE-ID;TZID=Europe/Berlin:20260112T090000\r\nDTSTART;TZID=Europe/Berlin:20260112T110000\r\nDURATION:PT1H\r\nSUMMARY:Moved\r\nEND:VEVENT\r\nEND:VCALENDAR",
231 )),
232 )
233 .await;
234 ui.put(
235 "alice",
236 "/pim/calendars/alice/default/day.ics",
237 &event("day", "DTSTART;VALUE=DATE:20260110\r\nSUMMARY:Trip\r\n"),
238 )
239 .await;
240 ui.put(
241 "alice",
242 "/pim/addressbooks/alice/default/anna.vcf",
243 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:anna\r\nFN:Anna\r\nBDAY:1980-01-07\r\nEND:VCARD\r\n",
244 )
245 .await;
246
247 let get = |q: &str| {
248 let q = q.to_string();
249 let alice = ui.alice.clone();
250 async move { alice.get(&format!("/api/pim/instances?{q}")).await }
251 };
252 let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&tz=Europe/Berlin").await;
253 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
254 let all = r.json();
255 let list = all["instances"].as_array().unwrap();
256 let summaries: Vec<&str> = list
257 .iter()
258 .map(|i| i["summary"].as_str().unwrap())
259 .collect();
260 assert_eq!(
261 summaries,
262 ["Standup", "🎂 Anna (1980)", "Trip", "Moved", "Standup"],
263 "{all}"
264 );
265 assert_eq!(list[0]["start"], "2026-01-05T08:00:00Z");
266 assert_eq!(list[0]["location"], "Room 1");
267 assert_eq!(list[0]["recurrence_id"], "2026-01-05T08:00:00Z");
268 // All-day values start at midnight in the viewer's zone.
269 assert_eq!(list[2]["start"], "2026-01-09T23:00:00Z");
270 assert_eq!(list[2]["all_day"], true);
271 assert_eq!(list[2]["recurrence_id"], Value::Null);
272 assert_eq!(list[3]["recurrence_id"], "2026-01-12T08:00:00Z");
273 assert_eq!(list[1]["collection_id"], -1);
274 assert_eq!(all["truncated"], false);
275
276 // Only the chosen calendars.
277 let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&collections=-1").await;
278 assert_eq!(r.json()["instances"].as_array().unwrap().len(), 1);
279 // Bad ranges.
280 for q in [
281 "from=2026-01-01T00:00:00Z&to=2027-06-01T00:00:00Z",
282 "from=2026-02-01T00:00:00Z&to=2026-01-01T00:00:00Z",
283 "from=yesterday&to=2026-01-01T00:00:00Z",
284 ] {
285 assert_eq!(get(q).await.status, StatusCode::BAD_REQUEST, "{q}");
286 }
287
288 // A read-only loan shows to the borrower, not otherwise.
289 let q = "/api/pim/instances?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z";
290 let count = |v: Value| {
291 v["instances"]
292 .as_array()
293 .unwrap()
294 .iter()
295 .filter(|i| i["uid"] == "weekly")
296 .count()
297 };
298 assert_eq!(count(ui.bob.get(q).await.json()), 0);
299 let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
300 ui.alice
301 .post_json(
302 &format!("/api/pim/collections/{cal}/shares"),
303 &json!({"user": "bob", "mode": "ro"}),
304 )
305 .await;
306 assert_eq!(count(ui.bob.get(q).await.json()), 3);
307}
308
309#[tokio::test]
310async fn object_detail_for_events_and_contacts() {
311 let ui = Ui::new().await;
312 ui.put(
313 "alice",
314 "/pim/calendars/alice/default/m.ics",
315 &event(
316 "m",
317 &format!(
318 "DTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=2\r\nSUMMARY:Plan\r\nDESCRIPTION:Line 1\\nLine 2\r\nCATEGORIES:Work,Q1\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;CN=Bob;ROLE=OPT-PARTICIPANT:{BOB}\r\n"
319 ),
320 ),
321 )
322 .await;
323 let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
324 let r = ui
325 .alice
326 .get(&format!("/api/pim/collections/{cal}/objects/m.ics"))
327 .await;
328 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
329 let d = r.json();
330 assert_eq!(d["type"], "event");
331 assert_eq!(d["description"], "Line 1\nLine 2");
332 assert_eq!(d["categories"], json!(["Work", "Q1"]));
333 assert_eq!(d["rrule"], "FREQ=DAILY;COUNT=2");
334 assert_eq!(d["organizer"]["name"], "Alice");
335 assert_eq!(d["attendees"][1]["role"], "OPT-PARTICIPANT");
336 assert_eq!(d["attendees"][0]["is_owner"], true);
337 assert_eq!(d["can_edit"], true);
338 assert_eq!(d["can_reply"], false);
339 // Bob's copy: he may answer.
340 let bob = ui
341 .bob
342 .get("/api/pim/instances?from=2026-01-05T00:00:00Z&to=2026-01-06T00:00:00Z")
343 .await
344 .json();
345 let copy = &bob["instances"][0];
346 assert_eq!(copy["partstat"], "NEEDS-ACTION", "{bob}");
347 let d = ui
348 .bob
349 .get(&format!(
350 "/api/pim/collections/{}/objects/{}",
351 copy["collection_id"],
352 copy["name"].as_str().unwrap()
353 ))
354 .await
355 .json();
356 assert_eq!(d["can_reply"], true, "{d}");
357 // Someone else's object stays hidden.
358 let r = ui
359 .bob
360 .get(&format!("/api/pim/collections/{cal}/objects/m.ics"))
361 .await;
362 assert_eq!(r.status, StatusCode::NOT_FOUND);
363
364 // A contact with a photo, and a group.
365 let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
366 ui.put(
367 "alice",
368 "/pim/addressbooks/alice/default/c1.vcf",
369 &format!(
370 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Carla Díaz\r\nN:Díaz;Carla;;;\r\nORG:Acme;Sales\r\nitem1.EMAIL;TYPE=INTERNET:carla@example.com\r\nitem1.X-ABLABEL:_$!<Other>!$_\r\nTEL;TYPE=CELL:+49 1\r\nADR;TYPE=WORK:;;Main St 1;Berlin;;10115;Germany\r\nBDAY:--03-15\r\nPHOTO;ENCODING=b;TYPE=PNG:{png}\r\nEND:VCARD\r\n"
371 ),
372 )
373 .await;
374 ui.put(
375 "alice",
376 "/pim/addressbooks/alice/default/g.vcf",
377 "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nMEMBER:urn:uuid:gone\r\nEND:VCARD\r\n",
378 )
379 .await;
380 let book = ui.id(&ui.alice, "/pim/addressbooks/alice/default/").await;
381 let d = ui
382 .alice
383 .get(&format!("/api/pim/collections/{book}/objects/c1.vcf"))
384 .await
385 .json();
386 assert_eq!(d["type"], "contact");
387 assert_eq!(d["org"], "Acme, Sales");
388 assert_eq!(
389 d["emails"][0],
390 json!({"label": "other", "value": "carla@example.com"})
391 );
392 assert_eq!(d["phones"][0]["label"], "cell");
393 assert_eq!(
394 d["addresses"][0]["value"],
395 "Main St 1\n10115 Berlin\nGermany"
396 );
397 assert_eq!(d["birthday"], "--03-15");
398 let photo = d["photo_url"].as_str().unwrap().to_string();
399 assert_eq!(ui.alice.get(&photo).await.status, StatusCode::OK);
400 let g = ui
401 .alice
402 .get(&format!("/api/pim/collections/{book}/objects/g.vcf"))
403 .await
404 .json();
405 assert_eq!(g["is_group"], true);
406 assert_eq!(g["members"], json!(["Carla Díaz", "gone"]));
407
408 // The generated birthday calendar has details too.
409 let list = ui
410 .alice
411 .get("/api/pim/instances?from=2026-03-01T00:00:00Z&to=2026-04-01T00:00:00Z")
412 .await
413 .json();
414 let bday = &list["instances"][0];
415 let d = ui
416 .alice
417 .get(&format!(
418 "/api/pim/collections/-1/objects/{}",
419 bday["name"].as_str().unwrap()
420 ))
421 .await
422 .json();
423 assert_eq!(d["summary"], "🎂 Carla Díaz");
424 assert_eq!(d["can_edit"], false);
425}
426
427#[tokio::test]
428async fn contacts_are_searched_across_books() {
429 let ui = Ui::new().await;
430 ui.put(
431 "alice",
432 "/pim/addressbooks/alice/default/z.vcf",
433 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:Zoë Weiß\r\nEMAIL:zoe@example.com\r\nEND:VCARD\r\n",
434 )
435 .await;
436 ui.put(
437 "alice",
438 "/pim/addressbooks/alice/default/n.vcf",
439 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nN:Nobody;Nora;;;\r\nTEL:+1 555\r\nEND:VCARD\r\n",
440 )
441 .await;
442 let all = ui.alice.get("/api/pim/contacts").await.json();
443 let names: Vec<&str> = all
444 .as_array()
445 .unwrap()
446 .iter()
447 .map(|c| c["full_name"].as_str().unwrap())
448 .collect();
449 // The directory lists the accounts too.
450 assert_eq!(names, ["admin", "alice", "bob", "Nora Nobody", "Zoë Weiß"]);
451 let hits = ui.alice.get("/api/pim/contacts?q=ZOË").await.json();
452 assert_eq!(hits.as_array().unwrap().len(), 1);
453 assert_eq!(hits[0]["email"], "zoe@example.com");
454 let hits = ui.alice.get("/api/pim/contacts?q=555").await.json();
455 assert_eq!(hits[0]["full_name"], "Nora Nobody");
456 let own = ui.alice.get("/api/pim/contacts?collections=0").await.json();
457 assert_eq!(own.as_array().unwrap().len(), 3);
458}
459
460#[tokio::test]
461async fn invitations_are_listed_and_answered() {
462 let ui = Ui::new().await;
463 // A month ahead, so the invitation stays open whenever the test runs.
464 let day = chrono::Utc::now().date_naive() + chrono::Days::new(30);
465 let (first, second) = (
466 day.format("%Y%m%d"),
467 (day + chrono::Days::new(1)).format("%Y%m%d"),
468 );
469 let meeting = event(
470 "meet",
471 &format!(
472 "DTSTART:{first}T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Sync\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;RSVP=TRUE:{BOB}\r\n"
473 ),
474 );
475 ui.put("alice", "/pim/calendars/alice/default/meet.ics", &meeting)
476 .await;
477 let list = ui.bob.get("/api/pim/invitations").await.json();
478 let list = list.as_array().unwrap();
479 assert_eq!(list.len(), 1, "{list:?}");
480 assert_eq!(list[0]["summary"], "Sync");
481 assert_eq!(list[0]["recurring"], true);
482 assert_eq!(list[0]["recurrence_id"], Value::Null);
483 assert_eq!(
484 list[0]["start"],
485 format!("{}T09:00:00Z", day.format("%Y-%m-%d"))
486 );
487 assert!(
488 ui.alice
489 .get("/api/pim/invitations")
490 .await
491 .json()
492 .as_array()
493 .unwrap()
494 .is_empty()
495 );
496
497 // One instance first: an override in bob's copy, a REPLY for alice.
498 let reply = |partstat: &str, rid: Option<&str>| {
499 let mut body = json!({
500 "collection_id": list[0]["collection_id"],
501 "name": list[0]["name"],
502 "partstat": partstat,
503 });
504 if let Some(rid) = rid {
505 body["recurrence_id"] = json!(rid);
506 }
507 body
508 };
509 let r = ui
510 .bob
511 .post_json(
512 "/api/pim/invitations",
513 &reply(
514 "DECLINED",
515 Some(&format!(
516 "{}T09:00:00Z",
517 (day + chrono::Days::new(1)).format("%Y-%m-%d")
518 )),
519 ),
520 )
521 .await;
522 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
523 let org = ui
524 .dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "")
525 .await
526 .text()
527 .replace("\r\n ", "");
528 assert!(
529 org.contains(&format!("RECURRENCE-ID:{second}T090000Z")),
530 "{org}"
531 );
532 assert!(org.contains("PARTSTAT=DECLINED"), "{org}");
533 // The series is still open.
534 assert_eq!(
535 ui.bob
536 .get("/api/pim/invitations")
537 .await
538 .json()
539 .as_array()
540 .unwrap()
541 .len(),
542 1
543 );
544
545 let r = ui
546 .bob
547 .post_json("/api/pim/invitations", &reply("ACCEPTED", None))
548 .await;
549 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
550 assert!(
551 ui.bob
552 .get("/api/pim/invitations")
553 .await
554 .json()
555 .as_array()
556 .unwrap()
557 .is_empty()
558 );
559 let org = ui
560 .dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "")
561 .await
562 .text()
563 .replace("\r\n ", "");
564 let bob_line = org
565 .lines()
566 .find(|l| l.starts_with("ATTENDEE") && l.contains(BOB) && !l.contains("DECLINED"))
567 .unwrap_or_else(|| panic!("{org}"));
568 assert!(bob_line.contains("PARTSTAT=ACCEPTED"), "{org}");
569
570 // Bad answers.
571 let r = ui
572 .bob
573 .post_json("/api/pim/invitations", &reply("MAYBE", None))
574 .await;
575 assert_eq!(r.status, StatusCode::BAD_REQUEST);
576 let r = ui
577 .alice
578 .post_json("/api/pim/invitations", &reply("ACCEPTED", None))
579 .await;
580 assert_eq!(r.status, StatusCode::NOT_FOUND);
581}
582
583#[tokio::test]
584async fn files_are_previewed() {
585 let ui = Ui::new().await;
586 let file = ics(
587 "BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260105T090000Z\r\nSUMMARY:One\r\nEND:VEVENT\r\n\
588 BEGIN:VEVENT\r\nUID:b\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;VALUE=DATE:20260106\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:Two\r\nEND:VEVENT\r\n\
589 BEGIN:VEVENT\r\nUID:c\r\nSUMMARY:No start\r\nEND:VEVENT\r\n",
590 );
591 std::fs::write(ui.env.file("docs/cal.ics"), file).unwrap();
592 std::fs::write(
593 ui.env.file("docs/people.vcf"),
594 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ann\r\nEMAIL:ann@example.com\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ben\r\nEND:VCARD\r\n",
595 )
596 .unwrap();
597 std::fs::write(ui.env.file("docs/notes.txt"), "hello").unwrap();
598 let root = ui.root_id(0).await;
599 let get = |path: &str| {
600 let url = format!("/api/pim/preview?root={root}&path={path}&tz=Europe/Berlin");
601 let alice = ui.alice.clone();
602 async move { alice.get(&url).await }
603 };
604 let r = get("cal.ics").await;
605 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
606 let p = r.json();
607 assert_eq!(p["kind"], "calendar");
608 assert_eq!(p["total"], 3);
609 assert_eq!(p["invalid"], 0, "{p}");
610 assert_eq!(p["events"][2]["start"], Value::Null);
611 assert_eq!(
612 p["events"][0],
613 json!({"summary": "One", "start": "2026-01-05T09:00:00Z", "all_day": false, "recurring": false})
614 );
615 assert_eq!(p["events"][1]["start"], "2026-01-05T23:00:00Z");
616 assert_eq!(p["events"][1]["recurring"], true);
617 let p = get("people.vcf").await.json();
618 assert_eq!(p["kind"], "addressbook");
619 assert_eq!(p["contacts"][0]["email"], "ann@example.com");
620 assert_eq!(p["contacts"][1]["full_name"], "Ben");
621 assert_eq!(get("notes.txt").await.status, StatusCode::BAD_REQUEST);
622 assert_eq!(get("missing.ics").await.status, StatusCode::NOT_FOUND);
623 // Another account's root is out of reach.
624 let r = ui
625 .bob
626 .get(&format!("/api/pim/preview?root={root}&path=cal.ics"))
627 .await;
628 assert!(r.status.is_client_error(), "{}", r.status);
629}
630