feat add application feedback actions

Commit
43da59d89a5bf697d7e07aa89e875e425f501b52
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/app/memory_repo.ml
index 9595d814..0d910986 100644..100644
@@ -324,3 +324,45 @@
324 324 t.app_feedback;
325 325 Lwt.return true
326 326 end
327 Added:
328 Added: let update_app_feedback t ~author id ~message =
329 Added: let author_id = Trainee.id_to_string author in
330 Added: let updated = ref false in
331 Added: t.app_feedback <-
332 Added: List.map
333 Added: (fun (owner, report) ->
334 Added: if
335 Added: String.equal (Trainee.id_to_string owner) author_id
336 Added: && String.equal
337 Added: (Repository.app_feedback_id_to_string
338 Added: report.Repository.feedback_id)
339 Added: (Repository.app_feedback_id_to_string id)
340 Added: then begin
341 Added: updated := true;
342 Added: (owner, { report with message })
343 Added: end
344 Added: else (owner, report))
345 Added: t.app_feedback;
346 Added: Lwt.return !updated
347 Added:
348 Added: let delete_app_feedback t ~author id =
349 Added: let author_id = Trainee.id_to_string author in
350 Added: let owned (owner, report) =
351 Added: String.equal (Trainee.id_to_string owner) author_id
352 Added: && String.equal
353 Added: (Repository.app_feedback_id_to_string report.Repository.feedback_id)
354 Added: (Repository.app_feedback_id_to_string id)
355 Added: in
356 Added: let removed = List.exists owned t.app_feedback in
357 Added: if removed then begin
358 Added: t.app_feedback <- List.filter (fun item -> not (owned item)) t.app_feedback;
359 Added: t.app_feedback_votes <-
360 Added: List.filter
361 Added: (fun (feedback_id, _) ->
362 Added: not
363 Added: (String.equal
364 Added: (Repository.app_feedback_id_to_string feedback_id)
365 Added: (Repository.app_feedback_id_to_string id)))
366 Added: t.app_feedback_votes
367 Added: end;
368 Added: Lwt.return removed
lib/app/repository.ml
index 0ded7593..3c2673df 100644..100644
@@ -73,4 +73,10 @@
73 73
74 74 val upvote_app_feedback :
75 75 t -> voter:Trainee.id -> app_feedback_id -> bool Lwt.t
76 Added:
77 Added: val update_app_feedback :
78 Added: t -> author:Trainee.id -> app_feedback_id -> message:string -> bool Lwt.t
79 Added:
80 Added: val delete_app_feedback :
81 Added: t -> author:Trainee.id -> app_feedback_id -> bool Lwt.t
76 82 end
lib/app/repository.mli
index f52fa156..62cf72cc 100644..100644
@@ -124,4 +124,12 @@
124 124 val upvote_app_feedback :
125 125 t -> voter:Trainee.id -> app_feedback_id -> bool Lwt.t
126 126 (** Add one vote from another trainee. [false] means no vote was added. *)
127 Added:
128 Added: val update_app_feedback :
129 Added: t -> author:Trainee.id -> app_feedback_id -> message:string -> bool Lwt.t
130 Added: (** Update a report only when [author] owns it. *)
131 Added:
132 Added: val delete_app_feedback :
133 Added: t -> author:Trainee.id -> app_feedback_id -> bool Lwt.t
134 Added: (** Remove a report and its votes only when [author] owns it. *)
127 135 end
lib/app/service.ml
index 6464b2ec..0d8c4309 100644..100644
@@ -31,7 +31,7 @@
31 31 | Rejected e -> Evidence.Workout.pp_error ppf e
32 32
33 33 type edit_error = Unknown_workout | Rejected_edit of Evidence.Workout.error
34 Removed: type app_feedback_error = [ `Empty_message ]
34 Added: type app_feedback_error = [ `Empty_message | `Unknown_feedback ]
35 35
36 36 (* Shared helpers over a routine and a log, used by more than one use case. *)
37 37
@@ -271,6 +271,15 @@
271 271
272 272 let list t trainee = R.app_feedback t.repo ~viewer:trainee
273 273 let upvote t trainee id = R.upvote_app_feedback t.repo ~voter:trainee id
274 Added:
275 Added: let edit t trainee id ~message =
276 Added: if String.trim message = "" then Lwt.return (Error `Empty_message)
277 Added: else
278 Added: R.update_app_feedback t.repo ~author:trainee id ~message >|= function
279 Added: | true -> Ok ()
280 Added: | false -> Error `Unknown_feedback
281 Added:
282 Added: let remove t trainee id = R.delete_app_feedback t.repo ~author:trainee id
274 283 end
275 284
276 285 (* Re-export the use cases as one flat service, matching Service.mli. *)
@@ -302,4 +311,6 @@
302 311 let record_app_feedback = App_feedback.record
303 312 let app_feedback = App_feedback.list
304 313 let upvote_app_feedback = App_feedback.upvote
314 Added: let edit_app_feedback = App_feedback.edit
315 Added: let remove_app_feedback = App_feedback.remove
305 316 end
lib/app/service.mli
index e1139070..f07f51c3 100644..100644
@@ -196,8 +196,8 @@
196 196
197 197 (** {2 Application feedback} *)
198 198
199 Removed: type app_feedback_error = [ `Empty_message ]
200 Removed: (** Why an application feedback message was refused. *)
199 Added: type app_feedback_error = [ `Empty_message | `Unknown_feedback ]
200 Added: (** Why an application feedback operation was refused. *)
201 201
202 202 val record_app_feedback :
203 203 t ->
@@ -213,4 +213,16 @@
213 213 val upvote_app_feedback :
214 214 t -> Trainee.id -> Repository.app_feedback_id -> bool Lwt.t
215 215 (** Try to add the trainee's vote to another trainee's feedback. *)
216 Added:
217 Added: val edit_app_feedback :
218 Added: t ->
219 Added: Trainee.id ->
220 Added: Repository.app_feedback_id ->
221 Added: message:string ->
222 Added: (unit, app_feedback_error) result Lwt.t
223 Added: (** Edit one of the trainee's own messages. *)
224 Added:
225 Added: val remove_app_feedback :
226 Added: t -> Trainee.id -> Repository.app_feedback_id -> bool Lwt.t
227 Added: (** Remove one of the trainee's own messages. *)
216 228 end
lib/app/sqlite_repo.ml
index c891de7f..0a34b9bc 100644..100644
@@ -119,6 +119,19 @@
119 119 (t3 string int string ->. unit)
120 120 "INSERT INTO app_feedback_vote (feedback_trainee_id, feedback_seq, \
121 121 voter_id) VALUES (?, ?, ?)"
122 Added:
123 Added: let update_app_feedback =
124 Added: (t3 string string int ->. unit)
125 Added: "UPDATE app_feedback SET message = ? WHERE trainee_id = ? AND seq = ?"
126 Added:
127 Added: let delete_feedback_votes =
128 Added: (t2 string int ->. unit)
129 Added: "DELETE FROM app_feedback_vote WHERE feedback_trainee_id = ? AND \
130 Added: feedback_seq = ?"
131 Added:
132 Added: let delete_app_feedback =
133 Added: (t2 string int ->. unit)
134 Added: "DELETE FROM app_feedback WHERE trainee_id = ? AND seq = ?"
122 135 end
123 136
124 137 (* --- pool helper --- *)
@@ -428,3 +441,32 @@
428 441 run t (fun (module Db : Caqti_lwt.CONNECTION) ->
429 442 Db.exec Q.insert_vote (trainee, seq, voter))
430 443 >|= fun () -> true))
444 Added:
445 Added: let update_app_feedback t ~author id ~message =
446 Added: match split_app_feedback_id id with
447 Added: | None -> Lwt.return false
448 Added: | Some (trainee, seq) -> (
449 Added: run t (fun (module Db : Caqti_lwt.CONNECTION) ->
450 Added: Db.find_opt Q.feedback_owner (trainee, seq))
451 Added: >>= function
452 Added: | Some owner when String.equal owner (Trainee.id_to_string author) ->
453 Added: run t (fun (module Db : Caqti_lwt.CONNECTION) ->
454 Added: Db.exec Q.update_app_feedback (message, trainee, seq))
455 Added: >|= fun () -> true
456 Added: | _ -> Lwt.return false)
457 Added:
458 Added: let delete_app_feedback t ~author id =
459 Added: match split_app_feedback_id id with
460 Added: | None -> Lwt.return false
461 Added: | Some (trainee, seq) -> (
462 Added: run t (fun (module Db : Caqti_lwt.CONNECTION) ->
463 Added: Db.find_opt Q.feedback_owner (trainee, seq))
464 Added: >>= function
465 Added: | Some owner when String.equal owner (Trainee.id_to_string author) ->
466 Added: run t (fun (module Db : Caqti_lwt.CONNECTION) ->
467 Added: Db.exec Q.delete_feedback_votes (trainee, seq))
468 Added: >>= fun () ->
469 Added: run t (fun (module Db : Caqti_lwt.CONNECTION) ->
470 Added: Db.exec Q.delete_app_feedback (trainee, seq))
471 Added: >|= fun () -> true
472 Added: | _ -> Lwt.return false)
lib/web/assets/hito.css
index b74e03b0..05bdd495 100644..100644
@@ -915,3 +915,29 @@
915 915 color: var(--paper-raised);
916 916 background: var(--oxblood);
917 917 }
918 Added:
919 Added: .app-feedback-actions {
920 Added: display: flex;
921 Added: flex-wrap: wrap;
922 Added: gap: 0.6rem;
923 Added: align-items: flex-start;
924 Added: }
925 Added: .app-feedback-edit-form {
926 Added: display: grid;
927 Added: gap: 0.45rem;
928 Added: flex: 1 1 24rem;
929 Added: }
930 Added: .app-feedback-edit-form textarea {
931 Added: width: 100%;
932 Added: border: 1px solid var(--rule-strong);
933 Added: border-radius: var(--radius);
934 Added: padding: 0.65rem;
935 Added: color: var(--ink);
936 Added: background: var(--paper-raised);
937 Added: font: 400 1rem/1.45 var(--sans);
938 Added: resize: vertical;
939 Added: }
940 Added: .app-feedback-remove-form input,
941 Added: .app-feedback-vote input {
942 Added: min-height: 2.75rem;
943 Added: }
lib/web/handlers.ml
index bdade8bc..e58b6440 100644..100644
@@ -590,7 +590,10 @@
590 590 "Feedback submitted."
591 591 | Error `Empty_message ->
592 592 redirect_with_flash_raw request "/app-feedback?tab=write"
593 Removed: Present.app_feedback_empty)
593 Added: Present.app_feedback_empty
594 Added: | Error `Unknown_feedback ->
595 Added: redirect_with_flash_raw request "/app-feedback?tab=submitted"
596 Added: "That feedback is no longer available.")
594 597
595 598 let upvote t trainee request id =
596 599 guard_csrf request >>= function
@@ -604,6 +607,39 @@
604 607 redirect_with_flash_raw request "/app-feedback?tab=submitted"
605 608 (if added then "Vote recorded." else "Vote was not added.")
606 609
610 Added: let edit t trainee request id =
611 Added: decode_form Decode.app_feedback request >>= function
612 Added: | Error _ ->
613 Added: redirect_with_flash_raw request "/app-feedback?tab=submitted"
614 Added: Present.form_invalid
615 Added: | Ok message -> (
616 Added: Service.edit_app_feedback t.service trainee.Trainee.id
617 Added: (Repository.app_feedback_id id)
618 Added: ~message
619 Added: >>= function
620 Added: | Ok () ->
621 Added: redirect_with_flash_raw request "/app-feedback?tab=submitted"
622 Added: "Feedback updated."
623 Added: | Error `Empty_message ->
624 Added: redirect_with_flash_raw request "/app-feedback?tab=submitted"
625 Added: Present.app_feedback_empty
626 Added: | Error `Unknown_feedback ->
627 Added: redirect_with_flash_raw request "/app-feedback?tab=submitted"
628 Added: "That feedback is no longer available.")
629 Added:
630 Added: let remove t trainee request id =
631 Added: guard_csrf request >>= function
632 Added: | Error _ ->
633 Added: redirect_with_flash_raw request "/app-feedback?tab=submitted"
634 Added: Present.form_invalid
635 Added: | Ok () ->
636 Added: Service.remove_app_feedback t.service trainee.Trainee.id
637 Added: (Repository.app_feedback_id id)
638 Added: >>= fun removed ->
639 Added: redirect_with_flash_raw request "/app-feedback?tab=submitted"
640 Added: (if removed then "Feedback removed."
641 Added: else "That feedback is no longer available.")
642 Added:
607 643 let routes t =
608 644 [
609 645 Dream_html.get Routes.app_feedback (fun request ->
@@ -612,6 +648,10 @@
612 648 authenticated t request (fun trainee -> submit t trainee request));
613 649 Dream_html.post Routes.upvote_app_feedback (fun request id ->
614 650 authenticated t request (fun trainee -> upvote t trainee request id));
651 Added: Dream_html.post Routes.edit_app_feedback (fun request id ->
652 Added: authenticated t request (fun trainee -> edit t trainee request id));
653 Added: Dream_html.post Routes.remove_app_feedback (fun request id ->
654 Added: authenticated t request (fun trainee -> remove t trainee request id));
615 655 ]
616 656 end
617 657
lib/web/pages.ml
index 456d71d3..881ebc5c 100644..100644
@@ -1381,6 +1381,64 @@
1381 1381 [ type_ "submit"; Dream_html.string_attr "value" "%s" label ];
1382 1382 ]
1383 1383
1384 Added: let app_feedback_edit_form request report =
1385 Added: tag "form"
1386 Added: [
1387 Added: action Routes.edit_app_feedback
1388 Added: (Repository.app_feedback_id_to_string report.Repository.feedback_id);
1389 Added: post_form;
1390 Added: class_ "app-feedback-edit-form";
1391 Added: ]
1392 Added: [
1393 Added: Dream_html.csrf_tag request;
1394 Added: tag "label"
1395 Added: [
1396 Added: Dream_html.string_attr "for" "app-feedback-edit-%s"
1397 Added: (Repository.app_feedback_id_to_string report.Repository.feedback_id);
1398 Added: ]
1399 Added: [ txt "Edit feedback" ];
1400 Added: tag "textarea"
1401 Added: [
1402 Added: name "message";
1403 Added: Dream_html.string_attr "id" "app-feedback-edit-%s"
1404 Added: (Repository.app_feedback_id_to_string report.Repository.feedback_id);
1405 Added: Dream_html.string_attr "rows" "4";
1406 Added: required;
1407 Added: ]
1408 Added: [ txt "%s" report.Repository.message ];
1409 Added: void "input" [ type_ "submit"; value "Save edit" ];
1410 Added: ]
1411 Added:
1412 Added: let app_feedback_remove_form request report =
1413 Added: tag "form"
1414 Added: [
1415 Added: action Routes.remove_app_feedback
1416 Added: (Repository.app_feedback_id_to_string report.Repository.feedback_id);
1417 Added: post_form;
1418 Added: class_ "app-feedback-remove-form";
1419 Added: ]
1420 Added: [
1421 Added: Dream_html.csrf_tag request;
1422 Added: void "input" [ type_ "submit"; value "Remove feedback" ];
1423 Added: ]
1424 Added:
1425 Added: let app_feedback_actions request ~trainee report =
1426 Added: let own =
1427 Added: String.equal report.Repository.author
1428 Added: (Trainee.username_to_string trainee.Trainee.username)
1429 Added: in
1430 Added: if own then
1431 Added: tag "div"
1432 Added: [ class_ "app-feedback-actions" ]
1433 Added: [
1434 Added: app_feedback_edit_form request report;
1435 Added: app_feedback_remove_form request report;
1436 Added: ]
1437 Added: else
1438 Added: tag "div"
1439 Added: [ class_ "app-feedback-actions" ]
1440 Added: [ app_feedback_vote_form request ~trainee report ]
1441 Added:
1384 1442 let app_feedback request ?(logging = false) ~trainee ?(tab = `Write) ?error
1385 1443 reports =
1386 1444 let panel =
@@ -1424,7 +1482,7 @@
1424 1482 tag "p"
1425 1483 [ class_ "app-feedback-upvotes" ]
1426 1484 [ txt "Upvotes: %d" report.upvotes ];
1427 Removed: app_feedback_vote_form request ~trainee report;
1485 Added: app_feedback_actions request ~trainee report;
1428 1486 ])
1429 1487 reports));
1430 1488 ]
lib/web/routes.ml
index 98c2bd03..3fdd4593 100644..100644
@@ -20,6 +20,8 @@
20 20 let%path app_feedback = "/app-feedback"
21 21 let%path submit_app_feedback = "/app-feedback/submit"
22 22 let%path upvote_app_feedback = "/app-feedback/%s/upvote"
23 Added: let%path edit_app_feedback = "/app-feedback/%s/edit"
24 Added: let%path remove_app_feedback = "/app-feedback/%s/remove"
23 25 let%path feedback = "/feedback"
24 26 let%path record = "/logbook/%s"
25 27 let%path record_slot = "/logbook/%s/slots/%d"
test/test_service.ml
index e0637066..2a9c0401 100644..100644
@@ -628,6 +628,8 @@
628 628 (S.record_app_feedback s t ~submitted_at:(day 1) ~message:" \n ")
629 629 with
630 630 | Error `Empty_message -> ()
631 Added: | Error `Unknown_feedback ->
632 Added: Alcotest.fail "unexpected unknown feedback error"
631 633 | Ok _ -> Alcotest.fail "expected a blank message to be refused" );
632 634 ( "app feedback is stored newest first and visible globally",
633 635 `Quick,
@@ -716,6 +718,51 @@
716 718 Alcotest.(check int) "upvote count" 1 voted_bob.Repository.upvotes;
717 719 Alcotest.(check bool)
718 720 "viewer vote state" true voted_bob.Repository.viewer_upvoted );
721 Added: ( "application feedback CRUD is owner-scoped",
722 Added: `Quick,
723 Added: fun () ->
724 Added: let s, alice = fixture () in
725 Added: let bob =
726 Added: (ok (run (S.register s ~username:"bobby" ~password:"heavyduty1")))
727 Added: .Trainee.id
728 Added: in
729 Added: let report =
730 Added: ok
731 Added: (run
732 Added: (S.record_app_feedback s alice ~submitted_at:(day 1)
733 Added: ~message:"Original"))
734 Added: in
735 Added: (match
736 Added: run
737 Added: (S.edit_app_feedback s bob report.Repository.feedback_id
738 Added: ~message:"Not yours")
739 Added: with
740 Added: | Error `Unknown_feedback -> ()
741 Added: | _ -> Alcotest.fail "another trainee edited the report");
742 Added: (match
743 Added: run
744 Added: (S.edit_app_feedback s alice report.Repository.feedback_id
745 Added: ~message:"Edited")
746 Added: with
747 Added: | Ok () -> ()
748 Added: | _ -> Alcotest.fail "owner edit failed");
749 Added: let edited =
750 Added: List.find
751 Added: (fun current ->
752 Added: String.equal
753 Added: (Repository.app_feedback_id_to_string
754 Added: current.Repository.feedback_id)
755 Added: (Repository.app_feedback_id_to_string
756 Added: report.Repository.feedback_id))
757 Added: (run (S.app_feedback s bob))
758 Added: in
759 Added: Alcotest.(check string) "edited message" "Edited" edited.message;
760 Added: Alcotest.(check bool)
761 Added: "owner remove succeeds" true
762 Added: (run (S.remove_app_feedback s alice report.Repository.feedback_id));
763 Added: Alcotest.(check int)
764 Added: "removed from the list" 0
765 Added: (List.length (run (S.app_feedback s bob))) );
719 766 ]
720 767
721 768 let suite =
test/test_sqlite_repo.ml
index d61b43ff..79d6b00e 100644..100644
@@ -397,6 +397,72 @@
397 397 Alcotest.(check bool)
398 398 "owner does not see a self vote" false
399 399 own_view.Repository.viewer_upvoted) );
400 Added: ( "application feedback CRUD survives and enforces ownership",
401 Added: `Quick,
402 Added: fun () ->
403 Added: let path, uri = temp_uri () in
404 Added: Fun.protect
405 Added: ~finally:(fun () -> cleanup path)
406 Added: (fun () ->
407 Added: let report_id =
408 Added: let repo = connect uri in
409 Added: let s = S.make ~repo in
410 Added: let alice =
411 Added: (ok
412 Added: (run (S.register s ~username:"alice" ~password:"heavyduty1")))
413 Added: .Trainee.id
414 Added: in
415 Added: let bob =
416 Added: (ok
417 Added: (run (S.register s ~username:"bobby" ~password:"heavyduty1")))
418 Added: .Trainee.id
419 Added: in
420 Added: let report =
421 Added: ok
422 Added: (run
423 Added: (S.record_app_feedback s alice ~submitted_at:(day 1)
424 Added: ~message:"Original"))
425 Added: in
426 Added: (match
427 Added: run
428 Added: (S.edit_app_feedback s bob report.Repository.feedback_id
429 Added: ~message:"Not yours")
430 Added: with
431 Added: | Error `Unknown_feedback -> ()
432 Added: | _ -> Alcotest.fail "another trainee edited the report");
433 Added: (match
434 Added: run
435 Added: (S.edit_app_feedback s alice report.Repository.feedback_id
436 Added: ~message:"Edited")
437 Added: with
438 Added: | Ok () -> ()
439 Added: | _ -> Alcotest.fail "owner edit failed");
440 Added: report.Repository.feedback_id
441 Added: in
442 Added: let repo = connect uri in
443 Added: let s = S.make ~repo in
444 Added: let alice =
445 Added: Option.get
446 Added: (run
447 Added: (S.authenticate s ~username:"alice" ~password:"heavyduty1"))
448 Added: in
449 Added: let edited =
450 Added: List.find
451 Added: (fun report ->
452 Added: String.equal
453 Added: (Repository.app_feedback_id_to_string
454 Added: report.Repository.feedback_id)
455 Added: (Repository.app_feedback_id_to_string report_id))
456 Added: (run (S.app_feedback s alice.Trainee.id))
457 Added: in
458 Added: Alcotest.(check string)
459 Added: "edited message persists" "Edited" edited.Repository.message;
460 Added: Alcotest.(check bool)
461 Added: "owner remove succeeds" true
462 Added: (run (S.remove_app_feedback s alice.Trainee.id report_id));
463 Added: Alcotest.(check int)
464 Added: "removed report is absent" 0
465 Added: (List.length (run (S.app_feedback s alice.Trainee.id)))) );
400 466 ( "a username and password change survive a reconnect",
401 467 `Quick,
402 468 fun () ->
test/test_web.ml
index 890ce458..c4da28e5 100644..100644
@@ -1126,8 +1126,8 @@
1126 1126 (contains ~substring:"id=\"app-feedback-message\"" write_page
1127 1127 && contains ~substring:"<textarea" write_page);
1128 1128 Alcotest.(check bool)
1129 Removed: "posts to the app feedback endpoint" true
1130 Removed: (contains ~substring:"action=\"/app-feedback/submit\"" write_page);
1129 Added: "renders one primary submit action" true
1130 Added: (contains ~substring:"value=\"Submit feedback\"" write_page);
1131 1131 let submitted_page = body (get c "/app-feedback?tab=submitted") in
1132 1132 Alcotest.(check bool)
1133 1133 "submitted tab is selected" true
@@ -1220,8 +1220,30 @@
1220 1220 (contains ~substring:"value=\"Upvoted\"" ranked);
1221 1221 let bob_page = body (get bob "/app-feedback?tab=submitted") in
1222 1222 Alcotest.(check bool)
1223 Removed: "identifies the owner's feedback" true
1224 Removed: (contains ~substring:"Your feedback" bob_page) );
1223 Added: "shows owner actions" true
1224 Added: (contains ~substring:"action=\"/app-feedback/t2:1/edit\"" bob_page
1225 Added: && contains ~substring:"action=\"/app-feedback/t2:1/remove\""
1226 Added: bob_page);
1227 Added: let edit_token = Option.get (csrf_token bob_page) in
1228 Added: let edited =
1229 Added: post bob "/app-feedback/t2:1/edit"
1230 Added: [ ("dream.csrf", edit_token); ("message", "Edited report") ]
1231 Added: in
1232 Added: Alcotest.(check int) "edit redirects" 303 (status edited);
1233 Added: let edited_page = body (get bob "/app-feedback?tab=submitted") in
1234 Added: Alcotest.(check bool)
1235 Added: "shows edited feedback" true
1236 Added: (contains ~substring:"Edited report" edited_page);
1237 Added: let remove_token = Option.get (csrf_token edited_page) in
1238 Added: let removed =
1239 Added: post bob "/app-feedback/t2:1/remove"
1240 Added: [ ("dream.csrf", remove_token) ]
1241 Added: in
1242 Added: Alcotest.(check int) "remove redirects" 303 (status removed);
1243 Added: let after_remove = body (get bob "/app-feedback?tab=submitted") in
1244 Added: Alcotest.(check bool)
1245 Added: "removes the owner's feedback" false
1246 Added: (contains ~substring:"Edited report" after_remove) );
1225 1247 ] );
1226 1248 ( "web.profile",
1227 1249 [