Skip to main content

max / goingson

26.3 KB · 820 lines History Blame Raw
1 //! The project dashboard, driven through the router against a real database.
2
3 use std::sync::Arc;
4
5 use goingson_core::{
6 MilestoneStatus, NewMilestone, NewProject, NewTask, ProjectId, ProjectStatus, ProjectType,
7 };
8 use quasi_http::Serves as _;
9 use quasi_router::Outcome;
10 use quasi_router::{Params, Request, Response};
11
12 use crate::quasi::router;
13 use crate::state::{AppState, DESKTOP_USER_ID};
14
15 async fn state() -> Arc<AppState> {
16 let (state, _) = crate::test_utils::setup_test_state().await;
17 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
18 state
19 .db
20 .conn()
21 .unwrap()
22 .execute(
23 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
24 VALUES (?, ?, ?, ?, ?)",
25 rusqlite::params![
26 DESKTOP_USER_ID.to_string(),
27 "desktop@localhost",
28 "x",
29 "Desktop User",
30 &now,
31 ],
32 )
33 .unwrap();
34 state
35 }
36
37 fn project(state: &AppState) -> ProjectId {
38 state
39 .projects
40 .create(
41 DESKTOP_USER_ID,
42 NewProject {
43 name: "Ported".to_owned(),
44 description: String::new(),
45 project_type: ProjectType::SideProject,
46 status: ProjectStatus::Active,
47 },
48 )
49 .unwrap()
50 .id
51 }
52
53 fn milestone(state: &AppState, project: ProjectId, name: &str) -> goingson_core::Milestone {
54 state
55 .milestones
56 .create(
57 DESKTOP_USER_ID,
58 NewMilestone {
59 project_id: project,
60 name: name.to_owned(),
61 description: String::new(),
62 position: 0,
63 target_date: None,
64 },
65 )
66 .unwrap()
67 }
68
69 fn html(response: Response) -> String {
70 match response.outcome {
71 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
72 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
73 // A redirect has no body to render. `Outcome` is deliberately not
74 // `#[non_exhaustive]`, so this arm had to be written rather than falling
75 // into a wildcard that returned empty markup and looked like a screen
76 // that rendered nothing.
77 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
78 // Same reasoning one step along: an overlay is a screen, but it is not
79 // the screen this route was asked for. Rendering it here would let a
80 // route that answered with the command palette pass as the page.
81 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
82 Outcome::Anchored { .. } => {
83 panic!("expected content, got a screen drawn at a point on it")
84 }
85 Outcome::Suggestions { field, .. } => {
86 panic!("expected content, got a suggestion list for `{field}`")
87 }
88 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
89 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
90 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
91 // not content, and not a place either.
92 Outcome::Started { region, .. } => {
93 panic!("expected content, got work started in `{region}`")
94 }
95 }
96 }
97
98 fn get(state: &AppState, path: &str, params: Params) -> Response {
99 router()
100 .handle(state, Request::get(path).carrying(params))
101 .expect("the route answers")
102 }
103
104 fn post(state: &AppState, path: &str, params: Params) -> Response {
105 router()
106 .handle(state, Request::post(path).sending(params))
107 .expect("the route answers")
108 }
109
110 /// A write made from a filtered view: what the control sent, and where it was
111 /// sent from. The two never merge, which is why a screen may filter on the same
112 /// name it writes.
113 fn viewing_post(state: &AppState, path: &str, carried: Params) -> Response {
114 router()
115 .handle(state, Request::post(path).carrying(carried))
116 .expect("the route answers")
117 }
118
119 fn dashboard(state: &AppState, project: ProjectId) -> String {
120 html(get(
121 state,
122 &format!("/projects/{project}/dashboard"),
123 Params::new(),
124 ))
125 }
126
127 #[tokio::test]
128 async fn an_empty_dashboard_says_so_in_every_column() {
129 let state = state().await;
130 let project = project(&state);
131 let page = dashboard(&state, project);
132
133 // Four columns, each saying what it has none of. An empty column that
134 // renders nothing reads as a column that failed to load.
135 assert!(page.contains("No tasks linked yet."));
136 assert!(page.contains("No events linked yet."));
137 assert!(page.contains("No emails linked yet."));
138 assert!(page.contains("No attachments yet."));
139 assert!(page.contains("No milestones yet"));
140 }
141
142 #[tokio::test]
143 async fn a_dashboard_of_nothing_is_still_four_named_regions() {
144 let state = state().await;
145 let project = project(&state);
146 let page = dashboard(&state, project);
147
148 for column in ["Tasks", "Events", "Emails", "Attachments"] {
149 assert!(page.contains(column), "{column} is named");
150 }
151 }
152
153 #[tokio::test]
154 async fn a_missing_project_is_a_not_found_rather_than_a_panic() {
155 let state = state().await;
156 let error = router()
157 .handle(
158 &state,
159 Request::get(format!("/projects/{}/dashboard", uuid::Uuid::nil())),
160 )
161 .expect_err("no such project");
162 assert_eq!(error.class.http_status(), 404);
163 }
164
165 #[tokio::test]
166 async fn the_dashboard_route_is_not_swallowed_by_the_detail_route() {
167 // `/projects/{id}` and `/projects/{id}/dashboard` are different lengths so
168 // they cannot collide, but the detail route is registered first and this is
169 // the assertion that says the composition order does not matter.
170 let state = state().await;
171 let project = project(&state);
172
173 let detail = get(&state, &format!("/projects/{project}"), Params::new());
174 assert_eq!(detail.target(), Some("projects-detail"));
175
176 let Outcome::Screen(_) = get(
177 &state,
178 &format!("/projects/{project}/dashboard"),
179 Params::new(),
180 )
181 .outcome
182 else {
183 panic!("the dashboard answers with a screen");
184 };
185 }
186
187 #[tokio::test]
188 async fn all_tasks_complete_is_a_different_thing_from_no_tasks() {
189 let state = state().await;
190 let project = project(&state);
191 let task = state
192 .tasks
193 .create(
194 DESKTOP_USER_ID,
195 NewTask::builder("Done")
196 .title("Done")
197 .project_id(project)
198 .build(),
199 )
200 .unwrap();
201 state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap();
202
203 let page = dashboard(&state, project);
204 assert!(page.contains("All tasks complete."));
205 assert!(!page.contains("No tasks linked yet."));
206 }
207
208 #[tokio::test]
209 async fn a_linked_task_addresses_the_task_overview() {
210 // The two described screens meet: a dashboard row opens the overview this
211 // same router answers. Nothing wires them together beyond the address.
212 let state = state().await;
213 let project = project(&state);
214 let task = state
215 .tasks
216 .create(
217 DESKTOP_USER_ID,
218 NewTask::builder("Live")
219 .title("Live")
220 .project_id(project)
221 .build(),
222 )
223 .unwrap();
224
225 let page = dashboard(&state, project);
226 assert!(page.contains(&format!("hx-get=\"/tasks/{}\"", task.id)));
227
228 // And it answers.
229 let Outcome::Screen(_) = get(&state, &format!("/tasks/{}", task.id), Params::new()).outcome
230 else {
231 panic!("the overview answers");
232 };
233 }
234
235 #[tokio::test]
236 async fn a_milestone_draws_its_progress_as_a_bar_in_the_row() {
237 // The proportion finding's third and fourth call sites. `d0b58239` named a
238 // bar at 0.10.0 and could not reach a row; `da5666ae` closed that at 0.11.0
239 // with `RowPart::Proportion`. This asserted the text form until then.
240 let state = state().await;
241 let project = project(&state);
242 let target = milestone(&state, project, "Phase one");
243
244 let done = state
245 .tasks
246 .create(
247 DESKTOP_USER_ID,
248 NewTask::builder("Done")
249 .title("Done")
250 .project_id(project)
251 .milestone_id(target.id)
252 .build(),
253 )
254 .unwrap();
255 state.tasks.complete(done.id, DESKTOP_USER_ID).unwrap();
256
257 let page = dashboard(&state, project);
258 assert!(page.contains("Phase one"));
259 assert!(page.contains("row-proportion"));
260 assert!(page.contains("progress-fill"));
261 // The ratio is still readable. It moved from the meta slot into the bar's
262 // accessible name, which is what the concatenated text was for.
263 assert!(page.contains(r#"aria-label="1 of 1 tasks""#));
264 }
265
266 #[tokio::test]
267 async fn the_reorder_controls_are_disabled_at_the_ends_rather_than_hidden() {
268 // A control that vanishes at the edge of a list is a control the user has to
269 // discover twice. `Act::disabled` says what the JS's hidden spacer means.
270 let state = state().await;
271 let project = project(&state);
272 milestone(&state, project, "First");
273 milestone(&state, project, "Second");
274
275 let page = dashboard(&state, project);
276 assert!(page.contains("Move up"));
277 assert!(page.contains("Move down"));
278 assert!(page.contains("disabled"));
279 }
280
281 #[tokio::test]
282 async fn moving_a_milestone_changes_the_order_it_comes_back_in() {
283 let state = state().await;
284 let project = project(&state);
285 let first = milestone(&state, project, "First");
286 milestone(&state, project, "Second");
287
288 let before = dashboard(&state, project);
289 assert!(before.find("First").unwrap() < before.find("Second").unwrap());
290
291 let page = html(post(
292 &state,
293 &format!("/projects/{project}/milestones/{}/move", first.id),
294 Params::new().with("by", "1"),
295 ));
296 assert!(page.find("Second").unwrap() < page.find("First").unwrap());
297 }
298
299 #[tokio::test]
300 async fn moving_off_the_end_is_a_fresh_screen_rather_than_an_error() {
301 // The control that would send this is disabled, so arriving here means a
302 // stale screen, and the answer to a stale screen is a current one.
303 let state = state().await;
304 let project = project(&state);
305 let first = milestone(&state, project, "First");
306
307 let page = html(post(
308 &state,
309 &format!("/projects/{project}/milestones/{}/move", first.id),
310 Params::new().with("by", "-1"),
311 ));
312 assert!(page.contains("First"));
313 }
314
315 #[tokio::test]
316 async fn a_move_that_is_not_one_step_is_refused() {
317 let state = state().await;
318 let project = project(&state);
319 let first = milestone(&state, project, "First");
320
321 let error = router()
322 .handle(
323 &state,
324 Request::post(format!("/projects/{project}/milestones/{}/move", first.id))
325 .sending(Params::new().with("by", "7")),
326 )
327 .expect_err("only one step at a time");
328 assert_eq!(error.class.http_status(), 404);
329 }
330
331 #[tokio::test]
332 async fn deleting_a_milestone_takes_it_off_the_dashboard() {
333 let state = state().await;
334 let project = project(&state);
335 let going = milestone(&state, project, "Going");
336 milestone(&state, project, "Staying");
337
338 let page = html(post(
339 &state,
340 &format!("/projects/{project}/milestones/{}/delete", going.id),
341 Params::new(),
342 ));
343 assert!(!page.contains("Going"));
344 assert!(page.contains("Staying"));
345 }
346
347 #[tokio::test]
348 async fn the_completed_disclosure_is_an_address_not_module_state() {
349 // `showCompletedMilestones` is a module variable in the JS that a re-render
350 // throws away. Here the expanded dashboard has its own address, so it
351 // survives a reload and can be linked to.
352 let state = state().await;
353 let project = project(&state);
354 milestone(&state, project, "Open one");
355 let done = milestone(&state, project, "Finished");
356 state
357 .milestones
358 .update(
359 done.id,
360 DESKTOP_USER_ID,
361 "Finished",
362 "",
363 None,
364 &MilestoneStatus::Completed,
365 )
366 .unwrap();
367
368 let collapsed = dashboard(&state, project);
369 assert!(collapsed.contains("Show 1 completed"));
370 assert!(!collapsed.contains("Complete<"));
371
372 let expanded = html(get(
373 &state,
374 &format!("/projects/{project}/dashboard"),
375 Params::new().with("completed", "1"),
376 ));
377 assert!(expanded.contains("Hide completed"));
378 assert!(expanded.contains("Finished"));
379 }
380
381 #[tokio::test]
382 async fn a_write_answers_under_the_disclosure_it_carried() {
383 let state = state().await;
384 let project = project(&state);
385 let going = milestone(&state, project, "Going");
386 let done = milestone(&state, project, "Finished");
387 state
388 .milestones
389 .update(
390 done.id,
391 DESKTOP_USER_ID,
392 "Finished",
393 "",
394 None,
395 &MilestoneStatus::Completed,
396 )
397 .unwrap();
398
399 let page = html(viewing_post(
400 &state,
401 &format!("/projects/{project}/milestones/{}/delete", going.id),
402 Params::new().with("completed", "1"),
403 ));
404 // Still expanded afterwards. Dropping it here is how a delete reads as
405 // having collapsed the section.
406 assert!(page.contains("Hide completed"));
407 assert!(page.contains("Finished"));
408 }
409
410 #[tokio::test]
411 async fn the_attachments_column_asks_the_host_to_pick_a_file() {
412 // `attachments.pickAndAttach` opened the OS file picker, which is neither a
413 // route this app answers nor an external address. This was a
414 // `FieldKind::File` until 2026-08-22 on the belief that a Tauri host could
415 // hand back a path through one; it cannot, because the field renders
416 // `<input type="file">` into a webview and a browser reports a masked
417 // filename. `Action::by_host` is the ruled answer and `frontend/js/host.js`
418 // is the half that opens the dialog.
419 let state = state().await;
420 let project = project(&state);
421 let page = dashboard(&state, project);
422
423 assert!(page.contains("No attachments yet."));
424 assert!(!page.contains(r#"type="file""#), "{page}");
425 assert!(page.contains("data-sends="), "{page}");
426 assert!(page.contains("Attach a file"));
427 }
428
429 /// A file on disk to attach, named for the test that wants it.
430 fn a_file(name: &str, contents: &str) -> std::path::PathBuf {
431 let dir = std::env::temp_dir().join("goingson-quasi-attach-tests");
432 std::fs::create_dir_all(&dir).unwrap();
433 let path = dir.join(name);
434 std::fs::write(&path, contents).unwrap();
435 path
436 }
437
438 fn attach(state: &AppState, project: ProjectId, path: &std::path::Path) -> Response {
439 post(
440 state,
441 &format!("/projects/{project}/attachments"),
442 Params::new().with("file", path.to_str().unwrap()),
443 )
444 }
445
446 #[tokio::test]
447 async fn attaching_a_picked_file_answers_with_the_column_it_landed_in() {
448 let state = state().await;
449 let project = project(&state);
450 let response = attach(&state, project, &a_file("notes.txt", "hello"));
451
452 // The column alone, not the whole screen: attaching lands in one place, so
453 // an expanded milestones section survives it.
454 assert_eq!(response.target(), Some("dashboard-attachments"));
455 let page = html(response);
456 assert!(page.contains("notes.txt"));
457 assert!(page.contains("5 B"));
458 // Still offering the field, so a second file is one click away rather than
459 // a reload.
460 assert!(page.contains("data-sends="), "{page}");
461 }
462
463 #[tokio::test]
464 async fn attaching_nothing_is_refused_on_the_field_it_came_from() {
465 let state = state().await;
466 let project = project(&state);
467 let page = html(post(
468 &state,
469 &format!("/projects/{project}/attachments"),
470 Params::new(),
471 ));
472
473 assert!(page.contains("Choose a file to attach."));
474 assert!(page.contains("No attachments yet."));
475 }
476
477 #[tokio::test]
478 async fn a_file_that_is_not_there_is_a_refusal_rather_than_a_failure() {
479 // The user's to fix by picking another file, so it comes back on the field
480 // instead of as a 500 that says nothing they can act on.
481 let state = state().await;
482 let project = project(&state);
483 let page = html(attach(
484 &state,
485 project,
486 std::path::Path::new("/nowhere/at/all.txt"),
487 ));
488
489 assert!(page.contains("File does not exist"));
490 }
491
492 #[tokio::test]
493 async fn an_attachment_opens_as_a_file_address() {
494 // The other half of the finding: opening is a one-way handoff, so it is a
495 // redirect to somewhere this router does not answer rather than content.
496 // The space in the name is the reason the address is percent-encoded — an
497 // unescaped one truncates it at the first gap.
498 let state = state().await;
499 let project = project(&state);
500 attach(&state, project, &a_file("field notes.txt", "hello"));
501
502 let attachment = state
503 .attachments
504 .list_for_project(project, DESKTOP_USER_ID)
505 .unwrap()
506 .pop()
507 .expect("the attach landed");
508
509 let response = get(
510 &state,
511 &format!("/projects/{project}/attachments/{}/open", attachment.id),
512 Params::new(),
513 );
514 let Outcome::Goto(action) = response.outcome else {
515 panic!("opening hands the file over rather than answering with a screen");
516 };
517 let quasi_router::Destination::External(url) = action.destination else {
518 panic!("a file lives outside anything this router answers");
519 };
520 assert!(url.starts_with("file:///"));
521 assert!(url.ends_with("field%20notes.txt"));
522 // And it is really there, under its own name rather than under its hash.
523 let path = url.replace("file://", "").replace("%20", " ");
524 assert_eq!(std::fs::read_to_string(path).unwrap(), "hello");
525 }
526
527 #[tokio::test]
528 async fn opening_an_attachment_that_is_not_there_is_a_not_found() {
529 let state = state().await;
530 let project = project(&state);
531 let error = router()
532 .handle(
533 &state,
534 Request::get(format!(
535 "/projects/{project}/attachments/{}/open",
536 uuid::Uuid::nil()
537 )),
538 )
539 .expect_err("no such attachment");
540 assert_eq!(error.class.http_status(), 404);
541 }
542
543 #[tokio::test]
544 async fn a_project_name_cannot_become_markup() {
545 let state = state().await;
546 let project = state
547 .projects
548 .create(
549 DESKTOP_USER_ID,
550 NewProject {
551 name: "<script>alert(1)</script>".to_owned(),
552 description: String::new(),
553 project_type: ProjectType::SideProject,
554 status: ProjectStatus::Active,
555 },
556 )
557 .unwrap();
558
559 let page = dashboard(&state, project.id);
560 assert!(!page.contains("<script>alert"));
561 assert!(page.contains("&lt;script&gt;"));
562 }
563
564 #[tokio::test]
565 async fn a_linked_task_says_whether_it_is_available() {
566 // `projects-render.js` grew the two markers on 2026-08-09 (`0df3488`),
567 // after this screen was described, and nothing checked the port against
568 // the counterpart it was ported from. This is that check.
569 let state = state().await;
570 let project = project(&state);
571 let blocker = state
572 .tasks
573 .create(
574 DESKTOP_USER_ID,
575 NewTask::builder("Do this first")
576 .project_id(project)
577 .build(),
578 )
579 .unwrap();
580 let blocked = state
581 .tasks
582 .create(
583 DESKTOP_USER_ID,
584 NewTask::builder("Then this").project_id(project).build(),
585 )
586 .unwrap();
587 state
588 .tasks
589 .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id)
590 .unwrap();
591
592 let page = dashboard(&state, project);
593
594 assert!(page.contains("Blocked"), "{page}");
595 assert!(page.contains("Unblocks 1"), "{page}");
596 }
597
598 #[tokio::test]
599 async fn the_add_form_asks_what_the_modal_asks() {
600 // `openNewMilestone` in `projects.js`: name (required), description,
601 // target date. No status, because a milestone nobody has created yet
602 // cannot already be completed.
603 let state = state().await;
604 let project = project(&state);
605
606 let form = html(get(
607 &state,
608 &format!("/projects/{project}/milestones/new"),
609 Params::new(),
610 ));
611 assert!(form.contains("name=\"name\""), "{form}");
612 assert!(form.contains("name=\"description\""), "{form}");
613 assert!(form.contains("name=\"target_date\""), "{form}");
614 assert!(!form.contains("name=\"status\""), "{form}");
615 }
616
617 #[tokio::test]
618 async fn the_edit_form_is_the_add_form_plus_status() {
619 let state = state().await;
620 let project = project(&state);
621 let existing = milestone(&state, project, "Shipping");
622
623 let form = html(get(
624 &state,
625 &format!("/projects/{project}/milestones/{}/edit", existing.id),
626 Params::new(),
627 ));
628 assert!(form.contains("Shipping"), "{form}");
629 assert!(form.contains("name=\"status\""), "{form}");
630 }
631
632 #[tokio::test]
633 async fn add_and_edit_are_addresses_rather_than_overlays() {
634 // The shape task edit established (goingson@2384df1): the control on the
635 // dashboard is a link to a form, not a second arrangement drawn on top.
636 let state = state().await;
637 let project = project(&state);
638 let existing = milestone(&state, project, "Shipping");
639
640 let page = dashboard(&state, project);
641 assert!(
642 page.contains(&format!("/projects/{project}/milestones/new")),
643 "{page}"
644 );
645 assert!(
646 page.contains(&format!(
647 "/projects/{project}/milestones/{}/edit",
648 existing.id
649 )),
650 "{page}"
651 );
652 }
653
654 #[tokio::test]
655 async fn creating_a_milestone_puts_it_on_the_dashboard() {
656 let state = state().await;
657 let project = project(&state);
658
659 let page = html(post(
660 &state,
661 &format!("/projects/{project}/milestones"),
662 Params::new()
663 .with("name", "Beta")
664 .with("description", "Feature complete")
665 .with("target_date", "2026-03-01"),
666 ));
667 assert!(page.contains("Beta"), "{page}");
668
669 let stored = state
670 .milestones
671 .list_by_project(project, DESKTOP_USER_ID)
672 .unwrap();
673 assert_eq!(stored.len(), 1);
674 assert_eq!(
675 stored[0].target_date,
676 Some(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
677 );
678 }
679
680 #[tokio::test]
681 async fn a_new_milestone_lands_last() {
682 // `position` is the count of what is already there, which is what
683 // `list_by_project` orders by. `move` is how it gets anywhere else.
684 let state = state().await;
685 let project = project(&state);
686 milestone(&state, project, "First");
687
688 post(
689 &state,
690 &format!("/projects/{project}/milestones"),
691 Params::new().with("name", "Second"),
692 );
693
694 let page = dashboard(&state, project);
695 assert!(page.find("First").unwrap() < page.find("Second").unwrap());
696 }
697
698 #[tokio::test]
699 async fn a_milestone_with_no_name_is_refused_and_the_typing_survives() {
700 let state = state().await;
701 let project = project(&state);
702
703 let page = html(post(
704 &state,
705 &format!("/projects/{project}/milestones"),
706 Params::new()
707 .with("name", "")
708 .with("description", "Typed and nearly lost"),
709 ));
710 assert!(page.contains("A milestone needs a name."), "{page}");
711 assert!(page.contains("Typed and nearly lost"), "{page}");
712 assert!(
713 state
714 .milestones
715 .list_by_project(project, DESKTOP_USER_ID)
716 .unwrap()
717 .is_empty()
718 );
719 }
720
721 #[tokio::test]
722 async fn a_date_nobody_can_parse_is_refused_rather_than_dropped() {
723 // The JS runs the field through `parseNaturalDate` before it submits, so
724 // the described form parses on this side. Silently storing `None` would
725 // lose a date the user did type.
726 let state = state().await;
727 let project = project(&state);
728
729 let page = html(post(
730 &state,
731 &format!("/projects/{project}/milestones"),
732 Params::new()
733 .with("name", "Beta")
734 .with("target_date", "whenever"),
735 ));
736 assert!(page.contains("Date not recognized"), "{page}");
737 assert!(
738 state
739 .milestones
740 .list_by_project(project, DESKTOP_USER_ID)
741 .unwrap()
742 .is_empty()
743 );
744 }
745
746 #[tokio::test]
747 async fn editing_a_milestone_saves_every_field() {
748 let state = state().await;
749 let project = project(&state);
750 let existing = milestone(&state, project, "Old");
751
752 post(
753 &state,
754 &format!("/projects/{project}/milestones/{}", existing.id),
755 Params::new()
756 .with("name", "New")
757 .with("description", "Rewritten")
758 .with("target_date", "2026-04-02")
759 .with("status", "Completed"),
760 );
761
762 let stored = state
763 .milestones
764 .get_by_id(existing.id, DESKTOP_USER_ID)
765 .unwrap()
766 .expect("still there");
767 assert_eq!(stored.name, "New");
768 assert_eq!(stored.description, "Rewritten");
769 assert_eq!(
770 stored.target_date,
771 Some(chrono::NaiveDate::from_ymd_opt(2026, 4, 2).unwrap())
772 );
773 assert_eq!(stored.status, MilestoneStatus::Completed);
774 }
775
776 #[tokio::test]
777 async fn a_milestone_belonging_to_another_project_is_not_found() {
778 // `get_by_id` scopes by user, not by project. Without the check the edit
779 // form would save to a milestone the address does not name.
780 let state = state().await;
781 let mine = project(&state);
782 let theirs = state
783 .projects
784 .create(
785 DESKTOP_USER_ID,
786 NewProject {
787 name: "Other".to_owned(),
788 description: String::new(),
789 project_type: ProjectType::SideProject,
790 status: ProjectStatus::Active,
791 },
792 )
793 .unwrap()
794 .id;
795 let elsewhere = milestone(&state, theirs, "Not yours");
796
797 let error = router()
798 .handle(
799 &state,
800 Request::get(format!("/projects/{mine}/milestones/{}/edit", elsewhere.id)),
801 )
802 .expect_err("the project in the address is checked");
803 assert_eq!(error.class.http_status(), 404);
804 }
805
806 #[tokio::test]
807 async fn new_is_a_form_rather_than_a_milestone_called_new() {
808 // The literal segment is routed above the capture. Read the other way,
809 // `/milestones/new` is a milestone id that does not parse.
810 let state = state().await;
811 let project = project(&state);
812
813 let form = html(get(
814 &state,
815 &format!("/projects/{project}/milestones/new"),
816 Params::new(),
817 ));
818 assert!(form.contains("Create milestone"), "{form}");
819 }
820