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