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