test verify POST-redirect-GET and document the policy

Add a focused test that a state-changing POST answers 303, renders no page body, and carries its success message to the redirected GET as a one-shot session flash that is gone on reload. Document the policy in the architecture: every form uses POST-redirect-GET to the originating page; a transient result rides the session flash or a query flag, while field-level validation re-renders the form in place with a 400 so entered values and per-field messages survive without breaking the redirect rule. The audit found every state-changing POST already redirects; this records the decision and guards it with a test.

Commit
0637e570682d04a8caac8dbbfe74d07c7ca5360f
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index bf336a5b..5c726c90 100644..100644
@@ -443,6 +443,19 @@
443 443 valid, whether recovery is complete, or whether a correction is
444 444 volume.
445 445
446 Added: *Every form uses POST-redirect-GET.* A state-changing POST answers with
447 Added: a 303 redirect to the page that started the flow — the workout, the
448 Added: logbook, the profile, or the app-feedback tab — so a refresh never
449 Added: re-posts. The redirect target carries any message two ways: a transient
450 Added: success or error note rides a one-shot session flash (=hito.flash=),
451 Added: read once and shown as a =data-hito-toast=; a confirmation that must
452 Added: survive a bookmarked reload rides a query flag such as
453 Added: =/profile?changed=username=. Field-level validation is the one
454 Added: exception: a rejected login, registration, or profile edit re-renders
455 Added: its form in place with a =400= status, so the entered values and the
456 Added: per-field messages survive. That response carries no history entry and
457 Added: never mutates state, so it does not break the redirect rule.
458 Added:
446 459 * Running it
447 460
448 461 #+begin_src sh
test/test_web.ml
index bf35e75c..8be9a26a 100644..100644
@@ -491,6 +491,32 @@
491 491 ] );
492 492 ( "web.flow",
493 493 [
494 Added: ( "a state-changing POST redirects and carries a flash to the GET",
495 Added: `Quick,
496 Added: fun () ->
497 Added: (* POST-redirect-GET: selecting a routine answers 303, and the
498 Added: success message rides a one-shot flash onto the redirected page
499 Added: rather than rendering inline on the POST response. *)
500 Added: let c = client () in
501 Added: let _ = sign_in_new c in
502 Added: let token = Option.get (csrf_token (body (get c "/"))) in
503 Added: let selection =
504 Added: post c "/routines/ideal/select" [ ("dream.csrf", token) ]
505 Added: in
506 Added: Alcotest.(check int) "selection redirects" 303 (status selection);
507 Added: Alcotest.(check bool)
508 Added: "the POST body renders no page" true
509 Added: (String.length (body selection) = 0
510 Added: || not (contains ~substring:"<h1" (body selection)));
511 Added: let home_page = body (get c "/") in
512 Added: Alcotest.(check bool)
513 Added: "the redirected GET shows the flash toast" true
514 Added: (contains ~substring:"data-hito-toast" home_page
515 Added: && contains ~substring:"Routine selected." home_page);
516 Added: let home_again = body (get c "/") in
517 Added: Alcotest.(check bool)
518 Added: "the flash is one-shot and gone on reload" false
519 Added: (contains ~substring:"Routine selected." home_again) );
494 520 ( "a signed-in trainee starts a workout and sees the exercise buttons",
495 521 `Quick,
496 522 fun () ->