Skip to main content

max / goingson

Add milestone add and edit to quasi::projects, the last gap in that module The dashboard could move and delete a milestone and could not make one. That was deliberate when the screen landed -- the JS opens a modal, and an edit form is a screen of its own rather than a control on the screen it edits -- so both controls were left out rather than pointed at routes that did not exist. This is those routes, on the shape task edit established (2384df1): GET /projects/{id}/milestones/new POST /projects/{id}/milestones GET /projects/{id}/milestones/{milestone}/edit POST /projects/{id}/milestones/{milestone} One form function for both, because the two differ by exactly one field, which is how `projects.js` differs them too: status is on the edit form only, since a milestone nobody has created yet cannot already be completed. Three things the port had to answer rather than describe: The date. `projects.js` runs the field through `parseNaturalDate` before it submits, so "next friday" reaches the API already a date. A described form has no transform step, so the parse moved server-side against the same core function the task form uses, and a date nobody can parse is refused rather than stored as nothing -- silently dropping it loses a date the user did type. The project in the address. `get_by_id` scopes by user and not by project, so `/projects/{a}/milestones/{b-of-another}/edit` would otherwise render a form that saves to a milestone the address does not name. Route order. `new` is a literal segment where its neighbours capture, so it is mounted above them; read the other way it is a milestone id that does not parse. Eleven tests, including the two refusals keeping the typing, the cross-project 404, and the segment-order one.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 00:38 UTC
Signed with PGP, not checked
Commit: 711ffe2b8d4a38f3b2afff5bfdeca88fceefdddf
Parent: 5118f39
2 files changed, +588 insertions, -15 deletions
@@ -23,6 +23,10 @@
23 23 //! # The shape
24 24 //!
25 25 //! - `GET /projects/{id}/dashboard` — the whole thing.
26 + //! - `GET /projects/{id}/milestones/new` — the add form.
27 + //! - `POST /projects/{id}/milestones` — create one.
28 + //! - `GET /projects/{id}/milestones/{milestone}/edit` — the edit form.
29 + //! - `POST /projects/{id}/milestones/{milestone}` — save one.
26 30 //! - `POST /projects/{id}/milestones/{milestone}/move` — reorder, `by=-1|1`.
27 31 //! - `POST /projects/{id}/milestones/{milestone}/delete` — delete one.
28 32 //! - `POST /projects/{id}/attachments` — attach the picked file.
@@ -44,7 +48,7 @@
44 48 use goingson_core::{
45 49 Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus,
46 50 };
47 - use quasi_router::screen::{Act, Field, Meter, Row, Tag};
51 + use quasi_router::screen::{Act, Choice, Field, Meter, Row, Tag};
48 52 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
49 53
50 54 use super::{filtered_by, project_id, status_tone, type_label};
@@ -281,25 +285,35 @@
281 285 let up = Act::new("Move up", move_by(-1));
282 286 let down = Act::new("Move down", move_by(1));
283 287
284 - row.act(if at == 0 { up.disabled() } else { up })
285 - .act(if at + 1 == of { down.disabled() } else { down })
286 - .act(
287 - Act::new(
288 - "Delete",
289 - Action::post(format!(
290 - "/projects/{project}/milestones/{}/delete",
291 - milestone.id
292 - )),
293 - )
294 - .tone(makeover_layout::Tone::Danger),
288 + row.act(Act::new(
289 + "Edit",
290 + Action::get(format!(
291 + "/projects/{project}/milestones/{}/edit",
292 + milestone.id
293 + )),
294 + ))
295 + .act(if at == 0 { up.disabled() } else { up })
296 + .act(if at + 1 == of { down.disabled() } else { down })
297 + .act(
298 + Act::new(
299 + "Delete",
300 + Action::post(format!(
301 + "/projects/{project}/milestones/{}/delete",
302 + milestone.id
303 + )),
295 304 )
305 + .tone(makeover_layout::Tone::Danger),
306 + )
296 307 }
297 308
298 309 /// The milestones section.
299 310 ///
300 - /// Edit is absent for the reason it is absent on the task overview: it opens a
301 - /// modal form, and an edit form is a screen of its own rather than a control on
302 - /// the screen it edits. Add is absent for the same reason.
311 + /// Add and edit are addresses rather than modals, on the shape the task
312 + /// overview established: an edit form is a screen of its own rather than a
313 + /// control on the screen it edits. That is why they were absent when this
314 + /// screen first landed, and it is the same reason they are here now — the
315 + /// forms exist as `/projects/{id}/milestones/new` and
316 + /// `/projects/{id}/milestones/{milestone}/edit`, so the controls are links.
303 317 fn milestones(
304 318 project: ProjectId,
305 319 all: &[Milestone],
@@ -310,11 +324,18 @@
310 324 .iter()
311 325 .partition(|milestone| milestone.status != MilestoneStatus::Completed);
312 326
327 + let add = Node::act(
328 + "New milestone".to_owned(),
329 + Action::get(format!("/projects/{project}/milestones/new")),
330 + );
331 +
313 332 let mut out = vec![Node::section("Milestones")];
314 333 if all.is_empty() {
315 334 out.push(Node::empty("No milestones yet"));
335 + out.push(add);
316 336 return out;
317 337 }
338 + out.push(add);
318 339
319 340 let of = open.len();
320 341 out.push(Node::list(open.iter().enumerate().map(
@@ -587,6 +608,328 @@
587 608 ))
588 609 }
589 610
611 + /// The milestone form, for adding and for editing.
612 + ///
613 + /// One function for both, because the two differ by exactly one field. The JS
614 + /// draws the same modal twice (`openNewMilestone` and `openEditMilestone` in
615 + /// `projects.js`) and differs the same way: status is on the edit form only,
616 + /// since a milestone nobody has created yet cannot already be completed.
617 + ///
618 + /// `existing` is the milestone being edited, or `None` for a new one.
619 + /// `submitted` carries a refused submission back so the user's typing survives
620 + /// being told what was wrong with it, which is the shape `edit_fields` in
621 + /// `quasi::tasks` established.
622 + fn milestone_fields(
623 + existing: Option<&Milestone>,
624 + errors: &[(&str, String)],
625 + submitted: Option<&quasi_router::Params>,
626 + ) -> Vec<Field> {
627 + let error_for = |name: &str| {
628 + errors
629 + .iter()
630 + .find(|(field, _)| *field == name)
631 + .map(|(_, message)| message.clone())
632 + };
633 + let apply = |field: Field, name: &str| match error_for(name) {
634 + Some(message) => field.error(message),
635 + None => field,
636 + };
637 + // A refused submission wins over the stored value, and the stored value
638 + // over nothing. Reading the submission first is what stops a validation
639 + // error from handing back the row as it was and losing the edit.
640 + let submitted_value = |name: &str| {
641 + submitted.and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
642 + };
643 + let value_of = |name: &str, stored: String| submitted_value(name).unwrap_or(stored);
644 +
645 + let mut name = Field::new(makeover_layout::FieldKind::Text, "name", "Name")
646 + .required()
647 + .value(value_of(
648 + "name",
649 + existing.map(|m| m.name.clone()).unwrap_or_default(),
650 + ));
651 + name.placeholder = Some("What does reaching it mean?".to_owned());
652 +
653 + let mut description = Field::new(
654 + makeover_layout::FieldKind::Textarea,
655 + "description",
656 + "Description",
657 + )
658 + .value(value_of(
659 + "description",
660 + existing.map(|m| m.description.clone()).unwrap_or_default(),
661 + ));
662 + description.placeholder = Some("Anything the name does not cover (optional)".to_owned());
663 +
664 + let mut target = Field::new(
665 + makeover_layout::FieldKind::Text,
666 + "target_date",
667 + "Target Date (optional)",
668 + )
669 + .value(value_of(
670 + "target_date",
671 + existing
672 + .and_then(|m| m.target_date)
673 + .map(|date| date.format("%Y-%m-%d").to_string())
674 + .unwrap_or_default(),
675 + ));
676 + target.placeholder = Some("next friday, 2026-03-01...".to_owned());
677 +
678 + let mut fields = vec![
679 + apply(name, "name"),
680 + apply(description, "description"),
681 + apply(target, "target_date"),
682 + ];
683 +
684 + if let Some(milestone) = existing {
685 + fields.push(apply(
686 + Field::select(
687 + "status",
688 + "Status",
689 + MILESTONE_STATUSES
690 + .iter()
691 + .map(|status| Choice::new(*status, *status))
692 + .collect(),
693 + )
694 + .value(value_of("status", milestone.status.as_str().to_owned())),
695 + "status",
696 + ));
697 + }
698 +
699 + fields
700 + }
701 +
702 + /// The two states a milestone can be put into by hand.
703 + ///
704 + /// Spelled as the display strings rather than the db values, because
705 + /// `MilestoneStatus` parses the display form (`#[strum(serialize = "Open")]`)
706 + /// and `as_str` produces it, so the field's value and its options agree without
707 + /// a mapping in between.
708 + const MILESTONE_STATUSES: &[&str] = &["Open", "Completed"];
709 +
710 + /// The add or edit form as a screen of its own.
711 + ///
712 + /// Both are addresses rather than overlays, on the shape task edit established
713 + /// (goingson@2384df1) and for the reason recorded there: a modal is a second
714 + /// arrangement drawn over the first, and a screen that offers a control which
715 + /// opens a form over itself has to describe two arrangements at once. Cancel is
716 + /// the dashboard's own address, and the dashboard is rebuilt from the database
717 + /// rather than restored from memory.
718 + fn milestone_form(
719 + project: ProjectId,
720 + existing: Option<&Milestone>,
721 + errors: &[(&str, String)],
722 + submitted: Option<&quasi_router::Params>,
723 + ) -> Screen {
724 + let (title, action) = match existing {
725 + Some(milestone) => (
726 + format!("Edit {}", milestone.name),
727 + Action::post(format!("/projects/{project}/milestones/{}", milestone.id)),
728 + ),
729 + None => (
730 + "New milestone".to_owned(),
731 + Action::post(format!("/projects/{project}/milestones")),
732 + ),
733 + };
734 +
735 + let band = Slot::new("milestone-band", RegionKind::Band)
736 + .with(Node::page(title))
737 + .with(Node::act(
738 + "Cancel",
739 + Action::get(format!("/projects/{project}/dashboard")),
740 + ));
741 +
742 + let pane = Slot::new("milestone-form", RegionKind::Pane).with(Node::Form {
743 + action,
744 + submit: if existing.is_some() {
745 + "Save milestone".to_owned()
746 + } else {
747 + "Create milestone".to_owned()
748 + },
749 + fields: milestone_fields(existing, errors, submitted),
750 + });
751 +
752 + Screen::list_detail("Milestone", false)
753 + .with(band)
754 + .with(pane)
755 + }
756 +
757 + /// Load one milestone, refusing one that belongs to another project.
758 + ///
759 + /// `get_by_id` scopes by user and not by project, so the project in the address
760 + /// is checked here. Without it `/projects/{a}/milestones/{b-of-another}/edit`
761 + /// would render a form that saves to a milestone the address does not name.
762 + fn load_milestone(
763 + state: &AppState,
764 + project: ProjectId,
765 + id: goingson_core::MilestoneId,
766 + ) -> Result<Milestone, RouteError> {
767 + let milestone = state
768 + .milestones
769 + .get_by_id(id, DESKTOP_USER_ID)
770 + .map_err(|error| RouteError::internal(error.to_string()))?
771 + .ok_or_else(|| RouteError::not_found("no such milestone"))?;
772 + if milestone.project_id != project {
773 + return Err(RouteError::not_found("no such milestone"));
774 + }
775 + Ok(milestone)
776 + }
777 +
778 + /// What the JS form refuses, refused here.
779 + ///
780 + /// `required: true` on the name field is the whole of the JS validation, and a
781 + /// described `.required()` is the same claim to the renderer. Neither is a
782 + /// check: the form can be submitted past both, so the refusal has to exist on
783 + /// this side too.
784 + fn validate_milestone(name: &str) -> Vec<(&'static str, String)> {
785 + let mut errors = Vec::new();
786 + if name.is_empty() {
787 + errors.push(("name", "A milestone needs a name.".to_owned()));
788 + }
789 + errors
790 + }
791 +
792 + /// The target date, parsed the way the JS parses it.
793 + ///
794 + /// `projects.js` runs the field through `parseNaturalDate` before it submits,
795 + /// so "next friday" reaches the API as a date. The described form has no
796 + /// transform step, so the parse happens here instead, against the same core
797 + /// function the task form uses. A date is a day rather than an instant, so the
798 + /// time half of the parse is dropped.
799 + fn milestone_target(
800 + raw: &str,
801 + errors: &mut Vec<(&'static str, String)>,
802 + ) -> Option<chrono::NaiveDate> {
803 + if raw.is_empty() {
804 + return None;
805 + }
806 + match goingson_core::parse_natural_date(raw, Local::now().naive_local()) {
807 + Some(when) => Some(when.date()),
808 + None => {
809 + errors.push((
810 + "target_date",
811 + "Date not recognized. Try \"next friday\" or \"2026-03-01\".".to_owned(),
812 + ));
813 + None
814 + }
815 + }
816 + }
817 +
818 + /// The add form.
819 + fn new_milestone(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
820 + let project = project_id(&request)?;
821 + // Loaded rather than trusted: the form posts to this project, so an address
822 + // naming one that does not exist should say so now rather than on submit.
823 + state
824 + .projects
825 + .get_by_id(project, DESKTOP_USER_ID)
826 + .map_err(|error| RouteError::internal(error.to_string()))?
827 + .ok_or_else(|| RouteError::not_found("no such project"))?;
828 + Ok(milestone_form(project, None, &[], None).into())
829 + }
830 +
831 + /// Create it, or answer with the form saying why not.
832 + ///
833 + /// `position` is the count of what is already there, which is what
834 + /// `list_by_project` orders by and what the JS relies on: a new milestone lands
835 + /// last, and `move` is how it gets anywhere else.
836 + fn create_milestone(
837 + state: &AppState,
838 + request: quasi_router::Request,
839 + ) -> Result<Response, RouteError> {
840 + let project = project_id(&request)?;
841 + let field = |name: &str| {
842 + request
843 + .payload
844 + .get(name)
845 + .unwrap_or_default()
846 + .trim()
847 + .to_owned()
848 + };
849 + let name = field("name");
850 + let mut errors = validate_milestone(&name);
851 + let target_date = milestone_target(&field("target_date"), &mut errors);
852 +
853 + if !errors.is_empty() {
854 + return Ok(milestone_form(project, None, &errors, Some(&request.payload)).into());
855 + }
856 +
857 + let existing = state
858 + .milestones
859 + .list_by_project(project, DESKTOP_USER_ID)
860 + .map_err(|error| RouteError::internal(error.to_string()))?;
861 +
862 + state
863 + .milestones
864 + .create(
865 + DESKTOP_USER_ID,
866 + goingson_core::NewMilestone {
867 + project_id: project,
868 + name,
869 + description: field("description"),
870 + position: i32::try_from(existing.len()).unwrap_or(i32::MAX),
871 + target_date,
872 + },
873 + )
874 + .map_err(|error| RouteError::internal(error.to_string()))?;
875 +
876 + wrote(state, project, flag(&request, "completed"))
877 + }
878 +
879 + /// The edit form.
880 + fn edit_milestone(
881 + state: &AppState,
882 + request: quasi_router::Request,
883 + ) -> Result<Response, RouteError> {
884 + let project = project_id(&request)?;
885 + let milestone = load_milestone(state, project, milestone_id(&request)?)?;
886 + Ok(milestone_form(project, Some(&milestone), &[], None).into())
887 + }
888 +
889 + /// Save the edited milestone, or answer with the form saying why not.
890 + fn update_milestone(
891 + state: &AppState,
892 + request: quasi_router::Request,
893 + ) -> Result<Response, RouteError> {
894 + let project = project_id(&request)?;
895 + let milestone = load_milestone(state, project, milestone_id(&request)?)?;
896 +
897 + let field = |name: &str| {
898 + request
899 + .payload
900 + .get(name)
901 + .unwrap_or_default()
902 + .trim()
903 + .to_owned()
904 + };
905 + let name = field("name");
906 + let mut errors = validate_milestone(&name);
907 + let target_date = milestone_target(&field("target_date"), &mut errors);
908 + let status =
909 + super::super::parse_choice::<MilestoneStatus>(&request.payload, "status", &mut errors);
910 +
911 + let (Some(status), true) = (status, errors.is_empty()) else {
912 + return Ok(
913 + milestone_form(project, Some(&milestone), &errors, Some(&request.payload)).into(),
914 + );
915 + };
916 +
917 + state
918 + .milestones
919 + .update(
920 + milestone.id,
921 + DESKTOP_USER_ID,
922 + &name,
923 + &field("description"),
924 + target_date,
925 + &status,
926 + )
927 + .map_err(|error| RouteError::internal(error.to_string()))?
928 + .ok_or_else(|| RouteError::not_found("no such milestone"))?;
929 +
930 + wrote(state, project, flag(&request, "completed"))
931 + }
932 +
590 933 /// Move a milestone one place up or down among the open ones.
591 934 ///
592 935 /// `reorder` takes the whole order rather than a swap, so the handler reads the
@@ -654,6 +997,13 @@
654 997 pub(super) fn routes(router: Router<AppState>) -> Router<AppState> {
655 998 router
656 999 .get("/projects/{id}/dashboard", dashboard)
1000 + // Above `/milestones/{milestone}/...`, because `new` and `edit` are
1001 + // literal segments where the others capture: a router that matched the
1002 + // capture first would read `new` as a milestone id.
1003 + .get("/projects/{id}/milestones/new", new_milestone)
1004 + .post("/projects/{id}/milestones", create_milestone)
1005 + .get("/projects/{id}/milestones/{milestone}/edit", edit_milestone)
1006 + .post("/projects/{id}/milestones/{milestone}", update_milestone)
657 1007 .post("/projects/{id}/milestones/{milestone}/move", move_milestone)
658 1008 .post(
659 1009 "/projects/{id}/milestones/{milestone}/delete",
@@ -580,3 +580,226 @@
580 580 assert!(page.contains("Blocked"), "{page}");
581 581 assert!(page.contains("Unblocks 1"), "{page}");
582 582 }
583 +
584 + #[tokio::test]
585 + async fn the_add_form_asks_what_the_modal_asks() {
586 + // `openNewMilestone` in `projects.js`: name (required), description,
587 + // target date. No status, because a milestone nobody has created yet
588 + // cannot already be completed.
589 + let state = state().await;
590 + let project = project(&state);
591 +
592 + let form = html(get(
593 + &state,
594 + &format!("/projects/{project}/milestones/new"),
595 + Params::new(),
596 + ));
597 + assert!(form.contains("name=\"name\""), "{form}");
598 + assert!(form.contains("name=\"description\""), "{form}");
599 + assert!(form.contains("name=\"target_date\""), "{form}");
600 + assert!(!form.contains("name=\"status\""), "{form}");
601 + }
602 +
603 + #[tokio::test]
604 + async fn the_edit_form_is_the_add_form_plus_status() {
605 + let state = state().await;
606 + let project = project(&state);
607 + let existing = milestone(&state, project, "Shipping");
608 +
609 + let form = html(get(
610 + &state,
611 + &format!("/projects/{project}/milestones/{}/edit", existing.id),
612 + Params::new(),
613 + ));
614 + assert!(form.contains("Shipping"), "{form}");
615 + assert!(form.contains("name=\"status\""), "{form}");
616 + }
617 +
618 + #[tokio::test]
619 + async fn add_and_edit_are_addresses_rather_than_overlays() {
620 + // The shape task edit established (goingson@2384df1): the control on the
621 + // dashboard is a link to a form, not a second arrangement drawn on top.
622 + let state = state().await;
623 + let project = project(&state);
624 + let existing = milestone(&state, project, "Shipping");
625 +
626 + let page = dashboard(&state, project);
627 + assert!(
628 + page.contains(&format!("/projects/{project}/milestones/new")),
629 + "{page}"
630 + );
631 + assert!(
632 + page.contains(&format!(
633 + "/projects/{project}/milestones/{}/edit",
634 + existing.id
635 + )),
636 + "{page}"
637 + );
638 + }
639 +
640 + #[tokio::test]
641 + async fn creating_a_milestone_puts_it_on_the_dashboard() {
642 + let state = state().await;
643 + let project = project(&state);
644 +
645 + let page = html(post(
646 + &state,
647 + &format!("/projects/{project}/milestones"),
648 + Params::new()
649 + .with("name", "Beta")
650 + .with("description", "Feature complete")
651 + .with("target_date", "2026-03-01"),
652 + ));
653 + assert!(page.contains("Beta"), "{page}");
654 +
655 + let stored = state
656 + .milestones
657 + .list_by_project(project, DESKTOP_USER_ID)
658 + .unwrap();
659 + assert_eq!(stored.len(), 1);
660 + assert_eq!(
661 + stored[0].target_date,
662 + Some(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
663 + );
664 + }
665 +
666 + #[tokio::test]
667 + async fn a_new_milestone_lands_last() {
668 + // `position` is the count of what is already there, which is what
669 + // `list_by_project` orders by. `move` is how it gets anywhere else.
670 + let state = state().await;
671 + let project = project(&state);
672 + milestone(&state, project, "First");
673 +
674 + post(
675 + &state,
676 + &format!("/projects/{project}/milestones"),
677 + Params::new().with("name", "Second"),
678 + );
679 +
680 + let page = dashboard(&state, project);
681 + assert!(page.find("First").unwrap() < page.find("Second").unwrap());
682 + }
683 +
684 + #[tokio::test]
685 + async fn a_milestone_with_no_name_is_refused_and_the_typing_survives() {
686 + let state = state().await;
687 + let project = project(&state);
688 +
689 + let page = html(post(
690 + &state,
691 + &format!("/projects/{project}/milestones"),
692 + Params::new()
693 + .with("name", "")
694 + .with("description", "Typed and nearly lost"),
695 + ));
696 + assert!(page.contains("A milestone needs a name."), "{page}");
697 + assert!(page.contains("Typed and nearly lost"), "{page}");
698 + assert!(
699 + state
700 + .milestones
701 + .list_by_project(project, DESKTOP_USER_ID)
702 + .unwrap()
703 + .is_empty()
704 + );
705 + }
706 +
707 + #[tokio::test]
708 + async fn a_date_nobody_can_parse_is_refused_rather_than_dropped() {
709 + // The JS runs the field through `parseNaturalDate` before it submits, so
710 + // the described form parses on this side. Silently storing `None` would
711 + // lose a date the user did type.
712 + let state = state().await;
713 + let project = project(&state);
714 +
715 + let page = html(post(
716 + &state,
717 + &format!("/projects/{project}/milestones"),
718 + Params::new()
719 + .with("name", "Beta")
720 + .with("target_date", "whenever"),
721 + ));
722 + assert!(page.contains("Date not recognized"), "{page}");
723 + assert!(
724 + state
725 + .milestones
726 + .list_by_project(project, DESKTOP_USER_ID)
727 + .unwrap()
728 + .is_empty()
729 + );
730 + }
731 +
732 + #[tokio::test]
733 + async fn editing_a_milestone_saves_every_field() {
734 + let state = state().await;
735 + let project = project(&state);
736 + let existing = milestone(&state, project, "Old");
737 +
738 + post(
739 + &state,
740 + &format!("/projects/{project}/milestones/{}", existing.id),
741 + Params::new()
742 + .with("name", "New")
743 + .with("description", "Rewritten")
744 + .with("target_date", "2026-04-02")
745 + .with("status", "Completed"),
746 + );
747 +
748 + let stored = state
749 + .milestones
750 + .get_by_id(existing.id, DESKTOP_USER_ID)
751 + .unwrap()
752 + .expect("still there");
753 + assert_eq!(stored.name, "New");
754 + assert_eq!(stored.description, "Rewritten");
755 + assert_eq!(
756 + stored.target_date,
757 + Some(chrono::NaiveDate::from_ymd_opt(2026, 4, 2).unwrap())
758 + );
759 + assert_eq!(stored.status, MilestoneStatus::Completed);
760 + }
761 +
762 + #[tokio::test]
763 + async fn a_milestone_belonging_to_another_project_is_not_found() {
764 + // `get_by_id` scopes by user, not by project. Without the check the edit
765 + // form would save to a milestone the address does not name.
766 + let state = state().await;
767 + let mine = project(&state);
768 + let theirs = state
769 + .projects
770 + .create(
771 + DESKTOP_USER_ID,
772 + NewProject {
773 + name: "Other".to_owned(),
774 + description: String::new(),
775 + project_type: ProjectType::SideProject,
776 + status: ProjectStatus::Active,
777 + },
778 + )
779 + .unwrap()
780 + .id;
781 + let elsewhere = milestone(&state, theirs, "Not yours");
782 +
783 + let error = router()
784 + .handle(
785 + &state,
786 + Request::get(format!("/projects/{mine}/milestones/{}/edit", elsewhere.id)),
787 + )
788 + .expect_err("the project in the address is checked");
789 + assert_eq!(error.class.http_status(), 404);
790 + }
791 +
792 + #[tokio::test]
793 + async fn new_is_a_form_rather_than_a_milestone_called_new() {
794 + // The literal segment is routed above the capture. Read the other way,
795 + // `/milestones/new` is a milestone id that does not parse.
796 + let state = state().await;
797 + let project = project(&state);
798 +
799 + let form = html(get(
800 + &state,
801 + &format!("/projects/{project}/milestones/new"),
802 + Params::new(),
803 + ));
804 + assert!(form.contains("Create milestone"), "{form}");
805 + }