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