Skip to main content

max / goingson

24.4 KB · 761 lines History Blame Raw
1 //! The calendar, driven through the router against a real database.
2
3 use std::sync::Arc;
4
5 use chrono::{Duration, Utc};
6 use goingson_core::{Event, EventId, NewEvent, Recurrence};
7 use quasi_http::Serves as _;
8 use quasi_router::Outcome;
9 use quasi_router::{Params, Request, Response};
10
11 use crate::quasi::router;
12 use crate::state::{AppState, DESKTOP_USER_ID};
13
14 async fn state() -> Arc<AppState> {
15 let (state, _) = crate::test_utils::setup_test_state().await;
16 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
17 state
18 .db
19 .conn()
20 .unwrap()
21 .execute(
22 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
23 VALUES (?, ?, ?, ?, ?)",
24 rusqlite::params![
25 DESKTOP_USER_ID.to_string(),
26 "desktop@localhost",
27 "x",
28 "Desktop User",
29 &now,
30 ],
31 )
32 .unwrap();
33 state
34 }
35
36 /// An event at an offset from now, so "upcoming" and "past" are decidable
37 /// without freezing the clock.
38 fn event_at(state: &AppState, title: &str, hours_from_now: i64) -> Event {
39 make(
40 state,
41 NewEvent::builder(title, Utc::now() + Duration::hours(hours_from_now))
42 .user_id(DESKTOP_USER_ID)
43 .build(),
44 )
45 }
46
47 fn make(state: &AppState, new: NewEvent) -> Event {
48 state.events.create(DESKTOP_USER_ID, new).unwrap()
49 }
50
51 fn html(response: Response) -> String {
52 match response.outcome {
53 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
54 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
55 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
56 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
57 Outcome::Anchored { .. } => {
58 panic!("expected content, got a screen drawn at a point on it")
59 }
60 Outcome::Suggestions { field, .. } => {
61 panic!("expected content, got a suggestion list for `{field}`")
62 }
63 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
64 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
65 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
66 // not content, and not a place either.
67 Outcome::Started { region, .. } => {
68 panic!("expected content, got work started in `{region}`")
69 }
70 }
71 }
72
73 fn get(state: &AppState, path: &str, params: Params) -> Response {
74 router()
75 .handle(state, Request::get(path).carrying(params))
76 .expect("the route answers")
77 }
78
79 fn post(state: &AppState, path: &str, params: Params) -> Response {
80 router()
81 .handle(state, Request::post(path).sending(params))
82 .expect("the route answers")
83 }
84
85 fn screen(state: &AppState) -> String {
86 html(get(state, "/events", Params::new()))
87 }
88
89 #[tokio::test]
90 async fn an_empty_calendar_says_so() {
91 let state = state().await;
92 assert!(screen(&state).contains("No events scheduled."));
93 }
94
95 #[tokio::test]
96 async fn the_three_sections_split_the_way_the_js_splits_them() {
97 let state = state().await;
98 event_at(&state, "Ahead", 24);
99 event_at(&state, "Behind", -24);
100 make(
101 &state,
102 NewEvent::builder("Every week", Utc::now() + Duration::hours(48))
103 .user_id(DESKTOP_USER_ID)
104 .recurrence(Recurrence::Weekly)
105 .build(),
106 );
107
108 let page = screen(&state);
109 // Recurring first: the rules are the shortest list and what a reader scans
110 // for, which is `events.js`'s own order.
111 let recurring = page.find("Recurring").expect("a recurring section");
112 let upcoming = page.find("Upcoming").expect("an upcoming section");
113 let past = page.find("Past").expect("a past section");
114 assert!(recurring < upcoming, "{page}");
115 assert!(upcoming < past, "{page}");
116 assert!(page.contains("Every week"), "{page}");
117 assert!(page.contains("Ahead"), "{page}");
118 assert!(page.contains("Behind"), "{page}");
119 }
120
121 #[tokio::test]
122 async fn a_recurring_rule_leads_with_its_pattern_rather_than_a_date() {
123 // The arbitrary date a weekly rule happens to start on says nothing about
124 // the rule, which is why the JS swaps the same cell.
125 let state = state().await;
126 make(
127 &state,
128 NewEvent::builder("Standup", Utc::now() + Duration::hours(48))
129 .user_id(DESKTOP_USER_ID)
130 .recurrence(Recurrence::Weekly)
131 .build(),
132 );
133
134 let page = screen(&state);
135 // `RecurrenceRule::display()` is the human label, not the enum name.
136 assert!(page.contains("Every week"), "{page}");
137 }
138
139 #[tokio::test]
140 async fn an_occurrence_is_not_filed_as_a_rule() {
141 // `is_template` is a recurrence AND not being an expanded instance. An
142 // event carrying only the first would put every occurrence in the rules
143 // section, which is the bug the split exists to avoid.
144 // The flag is set by recurrence expansion and is never persisted, so this
145 // flips it on the value rather than storing one.
146 let state = state().await;
147 let mut stored = make(
148 &state,
149 NewEvent::builder("Standup", Utc::now() + Duration::hours(2))
150 .user_id(DESKTOP_USER_ID)
151 .recurrence(Recurrence::Weekly)
152 .build(),
153 );
154 assert!(super::is_template(&stored), "the stored rule is a template");
155
156 stored.is_recurring_instance = true;
157 assert!(
158 !super::is_template(&stored),
159 "an expanded occurrence is not"
160 );
161 }
162
163 #[tokio::test]
164 async fn the_snoozed_filter_is_an_address_not_a_checkbox() {
165 // `filter-events-snoozed` is DOM state in the JS, so the filtered view has
166 // no address. Here it does.
167 let state = state().await;
168 let hidden = event_at(&state, "Snoozed away", 12);
169 state
170 .events
171 .snooze(hidden.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
172 .unwrap();
173
174 assert!(!screen(&state).contains("Snoozed away"));
175
176 let shown = html(get(&state, "/events", Params::new().with("snoozed", "1")));
177 assert!(shown.contains("Snoozed away"), "{shown}");
178 }
179
180 #[tokio::test]
181 async fn a_shown_snoozed_event_says_that_is_what_it_is() {
182 // Turning the filter on mixes snoozed rows in with the rest, so the row has
183 // to carry the fact or the two are indistinguishable.
184 let state = state().await;
185 let event = event_at(&state, "Only once", 12);
186 state
187 .events
188 .snooze(event.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
189 .unwrap();
190
191 let page = html(get(&state, "/events", Params::new().with("snoozed", "1")));
192 assert_eq!(page.matches("Only once").count(), 1, "{page}");
193 assert!(page.contains("Snoozed"), "{page}");
194 }
195
196 #[tokio::test]
197 async fn the_repository_read_is_not_the_command_read() {
198 // The port's one real correction. `events.js` calls `list_events`, which
199 // excludes snoozed rows; `list_all` underneath it does not. Reading the
200 // repository directly and calling it equivalent would put snoozed events on
201 // the screen on every visit.
202 let state = state().await;
203 let event = event_at(&state, "Hidden", 12);
204 state
205 .events
206 .snooze(event.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
207 .unwrap();
208
209 let straight_from_the_repository = state.events.list_all(DESKTOP_USER_ID).unwrap();
210 assert!(
211 straight_from_the_repository
212 .iter()
213 .any(|e| e.title == "Hidden"),
214 "list_all still carries it, which is why the screen filters"
215 );
216 assert!(!screen(&state).contains("Hidden"));
217 }
218
219 #[tokio::test]
220 async fn selecting_an_event_addresses_the_detail_pane() {
221 let state = state().await;
222 let event = event_at(&state, "Dentist", 6);
223
224 let page = screen(&state);
225 assert!(page.contains(&format!("/events/{}", event.id)), "{page}");
226
227 let detail = html(get(&state, &format!("/events/{}", event.id), Params::new()));
228 assert!(detail.contains("Dentist"), "{detail}");
229 }
230
231 #[tokio::test]
232 async fn deleting_an_event_takes_it_off_the_list() {
233 let state = state().await;
234 let going = event_at(&state, "Going", 6);
235 event_at(&state, "Staying", 8);
236
237 let page = html(post(
238 &state,
239 &format!("/events/{}/delete", going.id),
240 Params::new(),
241 ));
242 assert!(!page.contains("Going"), "{page}");
243 assert!(page.contains("Staying"), "{page}");
244 }
245
246 #[tokio::test]
247 async fn a_delete_made_from_the_filtered_view_answers_with_the_filtered_view() {
248 let state = state().await;
249 let going = event_at(&state, "Going", 6);
250 let snoozed = event_at(&state, "Snoozed away", 8);
251 state
252 .events
253 .snooze(snoozed.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
254 .unwrap();
255
256 let page = html(post(
257 &state,
258 &format!("/events/{}/delete", going.id),
259 Params::new().with("snoozed", "1"),
260 ));
261 assert!(page.contains("Snoozed away"), "{page}");
262 }
263
264 #[tokio::test]
265 async fn deleting_a_recurring_rule_is_refused_rather_than_guessed() {
266 // `confirmRecurringScope` asks "this occurrence or the series?" before the
267 // write lands. Nothing describes a write that pauses for an answer, so the
268 // route refuses rather than choosing for the user.
269 let state = state().await;
270 let rule = make(
271 &state,
272 NewEvent::builder("Standup", Utc::now() + Duration::hours(48))
273 .user_id(DESKTOP_USER_ID)
274 .recurrence(Recurrence::Weekly)
275 .build(),
276 );
277
278 let error = router()
279 .handle(
280 &state,
281 Request::post(format!("/events/{}/delete", rule.id)).sending(Params::new()),
282 )
283 .expect_err("a rule needs the scope question");
284 assert_eq!(error.class.http_status(), 404);
285 assert!(
286 state
287 .events
288 .get_by_id(rule.id, DESKTOP_USER_ID)
289 .unwrap()
290 .is_some(),
291 "the rule is still there"
292 );
293 }
294
295 #[tokio::test]
296 async fn the_rule_says_why_it_offers_no_delete() {
297 let state = state().await;
298 let rule = make(
299 &state,
300 NewEvent::builder("Standup", Utc::now() + Duration::hours(48))
301 .user_id(DESKTOP_USER_ID)
302 .recurrence(Recurrence::Weekly)
303 .build(),
304 );
305
306 let detail = html(get(&state, &format!("/events/{}", rule.id), Params::new()));
307 assert!(detail.contains("scope question"), "{detail}");
308 assert!(
309 !detail.contains(&format!("/events/{}/delete", rule.id)),
310 "{detail}"
311 );
312 }
313
314 #[tokio::test]
315 async fn list_is_a_route_rather_than_an_event_called_list() {
316 // The literal segment is mounted above the capture. Read the other way,
317 // `/events/list` is an event id that does not parse.
318 let state = state().await;
319 event_at(&state, "Dentist", 6);
320
321 let fragment = html(get(&state, "/events/list", Params::new()));
322 assert!(fragment.contains("Dentist"), "{fragment}");
323 }
324
325 #[tokio::test]
326 async fn an_event_that_is_not_there_is_a_not_found() {
327 let state = state().await;
328 let error = router()
329 .handle(
330 &state,
331 Request::get(format!("/events/{}", EventId::from(uuid::Uuid::nil()))),
332 )
333 .expect_err("no such event");
334 assert_eq!(error.class.http_status(), 404);
335 }
336
337 // The form. `8fdb814c`.
338
339 /// A submission with the questions the form asks, filled the way the boxes are
340 /// prefilled. One place, so a test about one answer says what it changed.
341 fn form(title: &str, start: &str) -> Params {
342 Params::new()
343 .with("title", title)
344 .with("description", "")
345 .with("start_time", start)
346 .with("end_time", "")
347 .with("location", "")
348 .with("recurrence", "None")
349 .with("tz_kind", "relative")
350 .with("timezone", "")
351 .with("block_type", "")
352 .with("contact_id", "")
353 .with("project_id", "")
354 }
355
356 /// The same submission with one answer replaced.
357 ///
358 /// In front rather than appended: `Params::get` answers with the first value
359 /// under a name, so a second `with` would leave the default standing.
360 fn instead(name: &str, value: impl Into<String>, params: Params) -> Params {
361 let mut out = Params::new().with(name, value);
362 out.absorb(params);
363 out
364 }
365
366 /// A wall clock in the shape the boxes offer and the parser reads back.
367 fn typed_at(hours_from_now: i64) -> String {
368 (chrono::Local::now() + Duration::hours(hours_from_now))
369 .format("%Y-%m-%dT%H:%M")
370 .to_string()
371 }
372
373 fn events(state: &AppState) -> Vec<Event> {
374 state.events.list_all(DESKTOP_USER_ID).unwrap()
375 }
376
377 #[tokio::test]
378 async fn the_form_asks_what_the_js_form_asked() {
379 let state = state().await;
380 let html = html(get(&state, "/events/new", Params::new()));
381
382 for name in [
383 "is_all_day",
384 "title",
385 "description",
386 "start_time",
387 "end_time",
388 "location",
389 "recurrence",
390 "tz_kind",
391 "timezone",
392 "block_type",
393 "contact_id",
394 "project_id",
395 ] {
396 assert!(html.contains(&format!("name=\"{name}\"")), "{name}: {html}");
397 }
398 // The reminders are one question answered N times, so the name on the wire
399 // is indexed and the blank the add control clones is already there.
400 assert!(html.contains("data-repeat=\"reminder\""), "{html}");
401 assert!(html.contains("Add reminder"), "{html}");
402 // One form and one submit, whatever the questions inside it are.
403 assert_eq!(html.matches("<form").count(), 1, "{html}");
404 }
405
406 /// The first of the two words this form waited for. `initTzKindConfig` shows
407 /// the zone box on one of the three kinds; the description says so, and the box
408 /// stays inside the form it submits with.
409 #[tokio::test]
410 async fn the_zone_box_is_out_on_the_anchored_kind_alone() {
411 let state = state().await;
412 let html = html(get(&state, "/events/new", Params::new()));
413
414 assert!(html.contains(r#"data-reveal="tz_kind""#), "{html}");
415 assert!(
416 html.contains(r#"data-reveal-values="[&quot;local&quot;]""#),
417 "{html}"
418 );
419 // Inside the form, or it would not be submitted with the rest of it.
420 let form = &html[html.find("<form").expect("a form")..];
421 let box_at = form.find(r#"name="timezone""#).expect("the zone box");
422 assert!(box_at < form.find("</form>").expect("the end"), "{form}");
423 // And it is revealed locally: no route is called to bring it out.
424 assert!(!html.contains("/events/timezone"), "{html}");
425 }
426
427 #[tokio::test]
428 async fn an_event_can_be_created_from_the_form() {
429 let state = state().await;
430 let answer = html(post(&state, "/events", form("Dentist", &typed_at(24))));
431
432 let made = events(&state);
433 assert_eq!(made.len(), 1, "{made:?}");
434 assert_eq!(made[0].title, "Dentist");
435 // Answered with the screen the write happened on, re-read, so the list
436 // shows what was just created.
437 assert!(answer.contains("Dentist"), "{answer}");
438 }
439
440 #[tokio::test]
441 async fn an_event_can_be_edited_from_the_form() {
442 let state = state().await;
443 let event = event_at(&state, "Dentist", 24);
444
445 // The form opens filled from the event.
446 let opened = html(get(
447 &state,
448 &format!("/events/{}/edit", event.id),
449 Params::new(),
450 ));
451 assert!(opened.contains("value=\"Dentist\""), "{opened}");
452
453 post(
454 &state,
455 &format!("/events/{}", event.id),
456 instead(
457 "location",
458 "Rose Street",
459 form("Dentist, moved", &typed_at(48)),
460 ),
461 );
462
463 let saved = state
464 .events
465 .get_by_id(event.id, DESKTOP_USER_ID)
466 .unwrap()
467 .expect("the event is still there");
468 assert_eq!(saved.title, "Dentist, moved");
469 assert_eq!(saved.location.as_deref(), Some("Rose Street"));
470 }
471
472 #[tokio::test]
473 async fn the_detail_pane_offers_edit() {
474 let state = state().await;
475 let event = event_at(&state, "Dentist", 24);
476 let detail = html(get(&state, &format!("/events/{}", event.id), Params::new()));
477 assert!(
478 detail.contains(&format!("/events/{}/edit", event.id)),
479 "{detail}"
480 );
481 }
482
483 /// The second word. One question, three answers, one submit, and the values
484 /// arrive as the `Vec<i64>` the column has always been.
485 #[tokio::test]
486 async fn reminders_are_one_question_answered_n_times() {
487 let state = state().await;
488 post(
489 &state,
490 "/events",
491 form("Standup", &typed_at(24))
492 .with("reminder[0]", "300")
493 .with("reminder[1]", "900")
494 .with("reminder[2]", "3600"),
495 );
496
497 let made = events(&state);
498 assert_eq!(made[0].reminder_offsets_seconds, vec![300, 900, 3600]);
499
500 // And the edit form offers one slot per answer, each under its own name.
501 let opened = html(get(
502 &state,
503 &format!("/events/{}/edit", made[0].id),
504 Params::new(),
505 ));
506 let standing = &opened[..opened.find("<template").expect("a blank slot")];
507 for at in 0..3 {
508 assert!(
509 standing.contains(&format!("name=\"reminder[{at}]\"")),
510 "{standing}"
511 );
512 }
513 assert!(standing.contains("value=\"3600\""), "{standing}");
514 }
515
516 /// A refusal names the answer it is about rather than the question, which is
517 /// the half a single error on the field cannot say.
518 #[tokio::test]
519 async fn a_reminder_that_is_not_a_number_names_its_own_slot() {
520 let state = state().await;
521 let answer = html(post(
522 &state,
523 "/events",
524 form("Standup", &typed_at(24))
525 .with("reminder[0]", "300")
526 .with("reminder[1]", "soon"),
527 ));
528
529 assert!(events(&state).is_empty(), "nothing was written");
530 assert!(answer.contains(r#"id="reminder[1]-error""#), "{answer}");
531 assert!(!answer.contains(r#"id="reminder[0]-error""#), "{answer}");
532 // What was typed comes back, including the slot that was fine.
533 assert!(answer.contains("value=\"300\""), "{answer}");
534 }
535
536 /// `sanitize_reminder_offsets` truncates at eight silently, so a form that did
537 /// not say the ceiling would take a ninth answer and throw it away.
538 #[tokio::test]
539 async fn more_than_eight_reminders_is_refused_rather_than_truncated() {
540 let state = state().await;
541 let mut params = form("Standup", &typed_at(24));
542 for at in 0..9 {
543 params = params.with(format!("reminder[{at}]"), (at * 60 + 60).to_string());
544 }
545 let answer = html(post(&state, "/events", params));
546
547 assert!(events(&state).is_empty(), "nothing was written");
548 assert!(answer.contains("At most 8 reminders"), "{answer}");
549 }
550
551 #[tokio::test]
552 async fn an_anchored_event_needs_a_zone_that_exists() {
553 let state = state().await;
554
555 let missing = html(post(
556 &state,
557 "/events",
558 instead("tz_kind", "local", form("Launch call", &typed_at(24))),
559 ));
560 assert!(
561 missing.contains("An anchored event needs a zone."),
562 "{missing}"
563 );
564
565 let unknown = html(post(
566 &state,
567 "/events",
568 instead(
569 "tz_kind",
570 "local",
571 instead(
572 "timezone",
573 "America/Nowhere",
574 form("Launch call", &typed_at(24)),
575 ),
576 ),
577 ));
578 assert!(unknown.contains("Unknown time zone."), "{unknown}");
579 assert!(events(&state).is_empty(), "nothing was written");
580 }
581
582 /// The wall clock the reader typed is what is stored, not the instant this
583 /// machine's zone would derive from it. `_eventTz` is explicit about why, and
584 /// it is the whole reason the civil columns exist.
585 #[tokio::test]
586 async fn an_anchored_event_keeps_the_wall_clock_that_was_typed() {
587 let state = state().await;
588 post(
589 &state,
590 "/events",
591 instead(
592 "tz_kind",
593 "local",
594 instead(
595 "timezone",
596 "America/Denver",
597 form("Launch call", "2026-12-25T10:00"),
598 ),
599 ),
600 );
601
602 let made = events(&state);
603 let stored = made.first().expect("the event was written");
604 assert_eq!(stored.tz_kind, goingson_core::TzKind::Local);
605 assert_eq!(stored.timezone.as_deref(), Some("America/Denver"));
606 assert_eq!(
607 stored.start_local.map(|when| when.to_string()),
608 Some("2026-12-25 10:00:00".to_owned())
609 );
610 // 10:00 in Denver in December is 17:00 UTC, whatever zone this test runs in.
611 assert_eq!(stored.start_time.to_rfc3339(), "2026-12-25T17:00:00+00:00");
612 }
613
614 #[tokio::test]
615 async fn a_form_that_is_refused_hands_back_what_was_typed() {
616 let state = state().await;
617 let answer = html(post(
618 &state,
619 "/events",
620 instead("location", "Rose Street", form("", &typed_at(24))),
621 ));
622
623 assert!(events(&state).is_empty(), "nothing was written");
624 assert!(answer.contains("An event needs a title."), "{answer}");
625 assert!(answer.contains("value=\"Rose Street\""), "{answer}");
626 }
627
628 #[tokio::test]
629 async fn an_end_before_the_start_is_refused() {
630 let state = state().await;
631 let answer = html(post(
632 &state,
633 "/events",
634 instead("end_time", typed_at(2), form("Dentist", &typed_at(24))),
635 ));
636
637 assert!(events(&state).is_empty(), "nothing was written");
638 assert!(
639 answer.contains("End time must be after start time"),
640 "{answer}"
641 );
642 }
643
644 #[tokio::test]
645 async fn a_date_the_parser_does_not_know_is_refused_rather_than_dropped() {
646 let state = state().await;
647 let answer = html(post(
648 &state,
649 "/events",
650 form("Dentist", "sometime after the thing"),
651 ));
652
653 assert!(events(&state).is_empty(), "nothing was written");
654 assert!(answer.contains("Date not recognized"), "{answer}");
655 }
656
657 /// The form asks the four patterns and cannot ask for the rule underneath
658 /// them, so an edit threads the stored rule through. Dropping it would be the
659 /// silent kind of loss.
660 #[tokio::test]
661 async fn an_edit_keeps_the_recurrence_rule_the_form_cannot_ask_for() {
662 let state = state().await;
663 let rule = goingson_core::RecurrenceRule {
664 pattern: Recurrence::Weekly,
665 interval: 2,
666 weekdays: Vec::new(),
667 monthly_spec: None,
668 until: None,
669 };
670 let event = make(
671 &state,
672 NewEvent::builder("Standup", Utc::now() + Duration::hours(48))
673 .user_id(DESKTOP_USER_ID)
674 .recurrence(Recurrence::Weekly)
675 .recurrence_rule(rule)
676 .build(),
677 );
678
679 post(
680 &state,
681 &format!("/events/{}", event.id),
682 instead(
683 "recurrence",
684 "Weekly",
685 form("Standup, renamed", &typed_at(48)),
686 ),
687 );
688
689 let saved = state
690 .events
691 .get_by_id(event.id, DESKTOP_USER_ID)
692 .unwrap()
693 .expect("the event is still there");
694 assert_eq!(saved.title, "Standup, renamed");
695 assert_eq!(
696 saved.recurrence_rule.map(|rule| rule.interval),
697 Some(2),
698 "the rule the form could not ask for was kept"
699 );
700 }
701
702 /// The JS form carries a project only as a hidden field set from elsewhere. A
703 /// described form asking nothing would clear the project of every event edited
704 /// from the calendar, so it asks, and "No Project" is one of the answers.
705 #[tokio::test]
706 async fn an_edit_can_keep_the_project_the_event_is_in() {
707 let state = state().await;
708 let project = state
709 .projects
710 .create(
711 DESKTOP_USER_ID,
712 goingson_core::NewProject {
713 name: "Launch".to_owned(),
714 description: String::new(),
715 project_type: goingson_core::ProjectType::SideProject,
716 status: goingson_core::ProjectStatus::Active,
717 },
718 )
719 .unwrap();
720 let event = make(
721 &state,
722 NewEvent::builder("Kickoff", Utc::now() + Duration::hours(24))
723 .user_id(DESKTOP_USER_ID)
724 .project_id(project.id)
725 .build(),
726 );
727
728 let opened = html(get(
729 &state,
730 &format!("/events/{}/edit", event.id),
731 Params::new(),
732 ));
733 assert!(opened.contains("Launch"), "{opened}");
734
735 post(
736 &state,
737 &format!("/events/{}", event.id),
738 instead(
739 "project_id",
740 project.id.to_string(),
741 form("Kickoff", &typed_at(24)),
742 ),
743 );
744
745 let saved = state
746 .events
747 .get_by_id(event.id, DESKTOP_USER_ID)
748 .unwrap()
749 .expect("the event is still there");
750 assert_eq!(saved.project_id, Some(project.id));
751 }
752
753 #[tokio::test]
754 async fn new_is_a_route_rather_than_an_event_called_new() {
755 // The literal segment is mounted above the capture, the same way
756 // `/events/list` is. Read the other way, this is an id that does not parse.
757 let state = state().await;
758 let html = html(get(&state, "/events/new", Params::new()));
759 assert!(html.contains("New event"), "{html}");
760 }
761