3
3
the rendered page, exactly as a browser would. The in-memory repository
4
4
stands in for SQLite; session storage is Dream's in-memory back end. *)
5
5
6
-
Removed:
open Hito_persistence
7
-
Removed:
module Handlers = Hito_web.Handlers.Make (Memory_repo) (Bcrypt_hash)
8
-
Removed:
9
-
Removed:
(* A fresh, fully wired application: secret, in-memory sessions, routes.
10
-
Removed:
Registration is opened here so the auth-flow tests can create trainees via
11
-
Removed:
[/register]; production keeps it closed. *)
12
-
Removed:
let app () =
13
-
Removed:
let handlers =
14
-
Removed:
Handlers.make ~repo:(Memory_repo.create ()) ~registration_open:true ()
15
-
Removed:
in
16
-
Removed:
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers)
17
-
Removed:
|> fun handler -> Dream.set_secret "test-secret-value" handler
18
-
Removed:
19
-
Removed:
let status response = Dream.status response |> Dream.status_to_int
20
-
Removed:
let body response = Lwt_main.run (Dream.body response)
21
-
Removed:
22
-
Removed:
let contains ~substring string =
23
-
Removed:
let n = String.length substring in
24
-
Removed:
let rec at i =
25
-
Removed:
if i + n > String.length string then false
26
-
Removed:
else if String.sub string i n = substring then true
27
-
Removed:
else at (i + 1)
28
-
Removed:
in
29
-
Removed:
at 0
30
-
Removed:
31
-
Removed:
(* --- a cookie-carrying client --- *)
32
-
Removed:
33
-
Removed:
(* Extract cookies (name=value) from all Set-Cookie response headers. *)
34
-
Removed:
let cookies_of response =
35
-
Removed:
Dream.headers response "Set-Cookie"
36
-
Removed:
|> List.filter_map (fun sc ->
37
-
Removed:
match String.index_opt sc ';' with
38
-
Removed:
| Some i -> Some (String.sub sc 0 i)
39
-
Removed:
| None -> Some sc)
40
-
Removed:
41
-
Removed:
type client = { app : Dream.handler; mutable jar : string list }
42
-
Removed:
43
-
Removed:
let client () = { app = app (); jar = [] }
44
-
Removed:
45
-
Removed:
let cookie_header client =
46
-
Removed:
if client.jar = [] then [] else [ ("Cookie", String.concat "; " client.jar) ]
47
-
Removed:
48
-
Removed:
let remember client response =
49
-
Removed:
(* Replace cookies of the same name; keep the rest. *)
50
-
Removed:
List.iter
51
-
Removed:
(fun fresh ->
52
-
Removed:
let name =
53
-
Removed:
match String.index_opt fresh '=' with
54
-
Removed:
| Some i -> String.sub fresh 0 i
55
-
Removed:
| None -> fresh
56
-
Removed:
in
57
-
Removed:
client.jar <-
58
-
Removed:
fresh
59
-
Removed:
:: List.filter
60
-
Removed:
(fun old ->
61
-
Removed:
not
62
-
Removed:
(String.length old >= String.length name
63
-
Removed:
&& String.sub old 0 (String.length name) = name
64
-
Removed:
&& (String.length old = String.length name
65
-
Removed:
|| old.[String.length name] = '=')))
66
-
Removed:
client.jar)
67
-
Removed:
(cookies_of response)
68
-
Removed:
69
-
Removed:
let get client target =
70
-
Removed:
let response =
71
-
Removed:
Dream.test client.app
72
-
Removed:
(Dream.request ~method_:`GET ~target ~headers:(cookie_header client) "")
73
-
Removed:
in
74
-
Removed:
remember client response;
75
-
Removed:
response
76
-
Removed:
77
-
Removed:
let post client target fields =
78
-
Removed:
let body =
79
-
Removed:
fields
80
-
Removed:
|> List.map (fun (k, v) ->
81
-
Removed:
Dream.to_percent_encoded k ^ "=" ^ Dream.to_percent_encoded v)
82
-
Removed:
|> String.concat "&"
83
-
Removed:
in
84
-
Removed:
let response =
85
-
Removed:
Dream.test client.app
86
-
Removed:
(Dream.request ~method_:`POST ~target
87
-
Removed:
~headers:
88
-
Removed:
(("Content-Type", "application/x-www-form-urlencoded")
89
-
Removed:
:: cookie_header client)
90
-
Removed:
body)
91
-
Removed:
in
92
-
Removed:
remember client response;
93
-
Removed:
response
94
-
Removed:
95
-
Removed:
(* A tiny substring search, since Str is not a dependency here. *)
96
-
Removed:
let index_from ~needle haystack start =
97
-
Removed:
let n = String.length needle and h = String.length haystack in
98
-
Removed:
let rec at i =
99
-
Removed:
if i + n > h then None
100
-
Removed:
else if String.sub haystack i n = needle then Some i
101
-
Removed:
else at (i + 1)
102
-
Removed:
in
103
-
Removed:
if start < 0 then None else at start
104
-
Removed:
105
-
Removed:
(* Pull the CSRF token value out of a rendered form. *)
106
-
Removed:
let csrf_token html =
107
-
Removed:
match index_from ~needle:"name=\"dream.csrf\"" html 0 with
108
-
Removed:
| None -> None
109
-
Removed:
| Some idx -> (
110
-
Removed:
match index_from ~needle:"value=\"" html idx with
111
-
Removed:
| None -> None
112
-
Removed:
| Some v ->
113
-
Removed:
let start = v + String.length "value=\"" in
114
-
Removed:
let stop = String.index_from html start '"' in
115
-
Removed:
Some (String.sub html start (stop - start)))
116
-
Removed:
117
-
Removed:
let register client ~username ~password =
118
-
Removed:
let page = body (get client "/register") in
119
-
Removed:
let token = Option.get (csrf_token page) in
120
-
Removed:
post client "/register"
121
-
Removed:
[ ("dream.csrf", token); ("username", username); ("password", password) ]
122
-
Removed:
123
-
Removed:
let sign_in_new client =
124
-
Removed:
register client ~username:"lifter" ~password:"heavyduty1"
125
-
Removed:
126
-
Removed:
(* Sign in through the credential form, as a returning trainee would. Unlike
127
-
Removed:
[register], this exercises the [POST /login] path. *)
128
-
Removed:
let sign_in client ~username ~password =
129
-
Removed:
let page = body (get client "/login") in
130
-
Removed:
let token = Option.get (csrf_token page) in
131
-
Removed:
post client "/login"
132
-
Removed:
[ ("dream.csrf", token); ("username", username); ("password", password) ]
133
-
Removed:
134
-
Removed:
(* An application whose clock the test drives. A shared [Memory_repo] and a
135
-
Removed:
mutable [now] cell let a deep test advance time across the recovery gate,
136
-
Removed:
which the wall-clock default cannot. Registration is open so the test can
137
-
Removed:
create the trainee it drives. *)
138
-
Removed:
let clocked_client () =
139
-
Removed:
let now = ref 0 in
140
-
Removed:
let handlers =
141
-
Removed:
Handlers.make ~repo:(Memory_repo.create ())
142
-
Removed:
~now:(fun () -> Recovery.timestamp_of_unix_seconds !now)
143
-
Removed:
~registration_open:true ()
144
-
Removed:
in
145
-
Removed:
let app =
146
-
Removed:
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h ->
147
-
Removed:
Dream.set_secret "test-secret-value" h
148
-
Removed:
in
149
-
Removed:
({ app; jar = [] }, now)
150
-
Removed:
151
-
Removed:
let day n = n * 86_400
152
-
Removed:
153
-
Removed:
(* Drive a trainee to the start of a fresh Day 1 workout: select the ideal
154
-
Removed:
routine and begin without an override. Returns the client. *)
155
-
Removed:
let start_day_one c =
156
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
157
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
158
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
159
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
160
-
Removed:
161
-
Removed:
(* Record every slot of the in-progress Day 1 workout, one at a time, addressed
162
-
Removed:
by [?slot=]. Slot 0 is the flyes/incline-press pair; slot 3 is the
163
-
Removed:
french-press/dips pair; slots 1 and 2 are single lateral movements. *)
164
-
Removed:
let record_all_day_one_slots c =
165
-
Removed:
let record_pair slot comp_load =
166
-
Removed:
let token =
167
-
Removed:
Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot))))
168
-
Removed:
in
169
-
Removed:
post c ("/workout/slots/" ^ slot)
170
-
Removed:
[
171
-
Removed:
("dream.csrf", token);
172
-
Removed:
("iso_load", "20");
173
-
Removed:
("iso_reps", "9");
174
-
Removed:
("comp_load", comp_load);
175
-
Removed:
("comp_reps", "7");
176
-
Removed:
("extension", "");
177
-
Removed:
]
178
-
Removed:
in
179
-
Removed:
let record_single slot load reps =
180
-
Removed:
let token =
181
-
Removed:
Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot))))
182
-
Removed:
in
183
-
Removed:
post c ("/workout/slots/" ^ slot)
184
-
Removed:
[
185
-
Removed:
("dream.csrf", token); ("load", load); ("reps", reps); ("extension", "");
186
-
Removed:
]
187
-
Removed:
in
188
-
Removed:
let _ = record_pair "0" "60" in
189
-
Removed:
let _ = record_single "1" "12" "8" in
190
-
Removed:
let _ = record_single "2" "10" "9" in
191
-
Removed:
let _ = record_pair "3" "0" in
192
-
Removed:
()
193
-
Removed:
194
-
Removed:
let route_tests =
195
-
Removed:
[
196
-
Removed:
( "web.layout",
197
-
Removed:
[
198
-
Removed:
( "the desktop main surface is narrower than the shell",
199
-
Removed:
`Quick,
200
-
Removed:
fun () ->
201
-
Removed:
let c = client () in
202
-
Removed:
let stylesheet = body (get c "/assets/hito.css") in
203
-
Removed:
Alcotest.(check bool)
204
-
Removed:
"desktop page surface cap" true
205
-
Removed:
(contains ~substring:"@media (min-width: 42.0625rem)" stylesheet
206
-
Removed:
&& contains ~substring:"max-width: 52rem" stylesheet
207
-
Removed:
&& contains ~substring:"margin-inline: auto" stylesheet) );
208
-
Removed:
] );
209
-
Removed:
( "web.auth",
210
-
Removed:
[
211
-
Removed:
( "an unauthenticated visit to the overview redirects to sign-in",
212
-
Removed:
`Quick,
213
-
Removed:
fun () ->
214
-
Removed:
let c = client () in
215
-
Removed:
let response = get c "/" in
216
-
Removed:
Alcotest.(check int) "redirect" 303 (status response) );
217
-
Removed:
( "the sign-in page renders a CSRF-protected form",
218
-
Removed:
`Quick,
219
-
Removed:
fun () ->
220
-
Removed:
let c = client () in
221
-
Removed:
let page = body (get c "/login") in
222
-
Removed:
Alcotest.(check bool)
223
-
Removed:
"has csrf field" true
224
-
Removed:
(contains ~substring:"dream.csrf" page);
225
-
Removed:
Alcotest.(check bool)
226
-
Removed:
"has a password field" true
227
-
Removed:
(contains ~substring:"type=\"password\"" page);
228
-
Removed:
Alcotest.(check bool)
229
-
Removed:
"starts the main content with a page-level heading" true
230
-
Removed:
(contains ~substring:"<h1>Sign in</h1>" page);
231
-
Removed:
Alcotest.(check bool)
232
-
Removed:
"loads the app client and marks the sign-in form" true
233
-
Removed:
(contains ~substring:"data-hito-app-shell" page
234
-
Removed:
&& contains ~substring:"data-hito-app-form" page
235
-
Removed:
&& contains ~substring:"/assets/workout-client.js" page);
236
-
Removed:
Alcotest.(check bool)
237
-
Removed:
"offers a skip link to the main landmark" true
238
-
Removed:
(contains ~substring:"skip-link" page
239
-
Removed:
&& contains ~substring:"href=\"#main-content\"" page
240
-
Removed:
&& contains ~substring:"id=\"main-content\"" page) );
241
-
Removed:
( "registration signs the trainee in and reaches the overview",
242
-
Removed:
`Quick,
243
-
Removed:
fun () ->
244
-
Removed:
let c = client () in
245
-
Removed:
let registered = sign_in_new c in
246
-
Removed:
Alcotest.(check int)
247
-
Removed:
"registered, redirected" 303 (status registered);
248
-
Removed:
let overview = get c "/" in
249
-
Removed:
Alcotest.(check int) "authenticated overview" 200 (status overview);
250
-
Removed:
Alcotest.(check bool)
251
-
Removed:
"shows the routine catalog" true
252
-
Removed:
(contains ~substring:"Routines" (body overview));
253
-
Removed:
Alcotest.(check bool)
254
-
Removed:
"starts the overview with a page-level heading" true
255
-
Removed:
(contains ~substring:"<h1>Routines</h1>" (body overview));
256
-
Removed:
Alcotest.(check bool)
257
-
Removed:
"labels the logbook tab Logbook" true
258
-
Removed:
(contains ~substring:">Logbook</a>" (body overview));
259
-
Removed:
Alcotest.(check bool)
260
-
Removed:
"shows the username on a profile drop-down control" true
261
-
Removed:
(contains ~substring:"class=\"profile-menu\"" (body overview)
262
-
Removed:
&& contains ~substring:"<summary class=\"profile\""
263
-
Removed:
(body overview)
264
-
Removed:
&& contains ~substring:">lifter</summary>" (body overview));
265
-
Removed:
Alcotest.(check bool)
266
-
Removed:
"the drop-down offers Profile and Settings, both to /profile" true
267
-
Removed:
(contains ~substring:">Profile</a>" (body overview)
268
-
Removed:
&& contains ~substring:">Settings</a>" (body overview)
269
-
Removed:
&& contains ~substring:"href=\"/profile\"" (body overview));
270
-
Removed:
Alcotest.(check bool)
271
-
Removed:
"the drop-down offers Sign out" true
272
-
Removed:
(contains ~substring:"value=\"Sign out\"" (body overview));
273
-
Removed:
Alcotest.(check bool)
274
-
Removed:
"omits the header username" false
275
-
Removed:
(contains ~substring:"account-username" (body overview));
276
-
Removed:
Alcotest.(check bool)
277
-
Removed:
"declares the document language" true
278
-
Removed:
(contains ~substring:"<html lang=\"en\">" (body overview));
279
-
Removed:
Alcotest.(check bool)
280
-
Removed:
"marks the overview as the current navigation destination" true
281
-
Removed:
(contains ~substring:"aria-current=\"page\"" (body overview));
282
-
Removed:
Alcotest.(check bool)
283
-
Removed:
"shows primary navigation" true
284
-
Removed:
(contains ~substring:"primary-nav" (body overview));
285
-
Removed:
Alcotest.(check bool)
286
-
Removed:
"shows bottom navigation" true
287
-
Removed:
(contains ~substring:"bottom-nav" (body overview)) );
288
-
Removed:
( "authenticated pages carry inline nav and a header sign-out",
289
-
Removed:
`Quick,
290
-
Removed:
fun () ->
291
-
Removed:
let c = client () in
292
-
Removed:
let _ = sign_in_new c in
293
-
Removed:
let page = body (get c "/") in
294
-
Removed:
(* Inline affordances: the primary nav exists in the markup, and the
295
-
Removed:
sign-out form lives inside the profile drop-down, ready for
296
-
Removed:
responsive CSS to reveal or hide them. *)
297
-
Removed:
Alcotest.(check bool)
298
-
Removed:
"keeps the inline primary navigation" true
299
-
Removed:
(contains ~substring:"class=\"primary-nav\"" page);
300
-
Removed:
Alcotest.(check bool)
301
-
Removed:
"keeps the sign-out inside the profile menu" true
302
-
Removed:
(contains ~substring:"class=\"profile-menu\"" page
303
-
Removed:
&& contains ~substring:"value=\"Sign out\"" page);
304
-
Removed:
(* No hamburger menu at any width: the disclosure and its classes
305
-
Removed:
are gone. *)
306
-
Removed:
Alcotest.(check bool)
307
-
Removed:
"renders no hamburger menu" false
308
-
Removed:
(contains ~substring:"class=\"menu\"" page);
309
-
Removed:
Alcotest.(check bool)
310
-
Removed:
"renders no menu toggle" false
311
-
Removed:
(contains ~substring:"menu-toggle" page);
312
-
Removed:
Alcotest.(check bool)
313
-
Removed:
"renders no menu panel" false
314
-
Removed:
(contains ~substring:"menu-panel" page) );
315
-
Removed:
( "the header sign-out is a CSRF-protected control",
316
-
Removed:
`Quick,
317
-
Removed:
fun () ->
318
-
Removed:
let c = client () in
319
-
Removed:
let _ = sign_in_new c in
320
-
Removed:
let page = body (get c "/") in
321
-
Removed:
(* Isolate the profile-menu panel so the assertions read the
322
-
Removed:
sign-out control rather than another form on the page. *)
323
-
Removed:
let account =
324
-
Removed:
let start =
325
-
Removed:
Option.get
326
-
Removed:
(index_from ~needle:"class=\"profile-menu-drawer\"" page 0)
327
-
Removed:
in
328
-
Removed:
let stop = Option.get (index_from ~needle:"</form>" page start) in
329
-
Removed:
String.sub page start (stop - start)
330
-
Removed:
in
331
-
Removed:
Alcotest.(check bool)
332
-
Removed:
"carries a sign-out form" true
333
-
Removed:
(contains ~substring:"action=\"/logout\"" account);
334
-
Removed:
Alcotest.(check bool)
335
-
Removed:
"the sign-out is CSRF-protected" true
336
-
Removed:
(contains ~substring:"dream.csrf" account);
337
-
Removed:
Alcotest.(check bool)
338
-
Removed:
"the sign-out is labelled" true
339
-
Removed:
(contains ~substring:"Sign out" account) );
340
-
Removed:
( "navigation offers three actions that toggle with logging",
341
-
Removed:
`Quick,
342
-
Removed:
fun () ->
343
-
Removed:
let c = client () in
344
-
Removed:
let _ = sign_in_new c in
345
-
Removed:
(* Not logging: the middle action is Routine, labelled "Routine",
346
-
Removed:
and no workout link is present. The three actions are Home,
347
-
Removed:
Routine, and the username history link. *)
348
-
Removed:
let idle = body (get c "/") in
349
-
Removed:
Alcotest.(check bool)
350
-
Removed:
"labels the home action" true
351
-
Removed:
(contains ~substring:">Home</a>" idle);
352
-
Removed:
Alcotest.(check bool)
353
-
Removed:
"offers the routine action when idle" true
354
-
Removed:
(contains ~substring:">Routine</a>" idle);
355
-
Removed:
Alcotest.(check bool)
356
-
Removed:
"hides the current workout action when idle" false
357
-
Removed:
(contains ~substring:">Current workout</a>" idle);
358
-
Removed:
(* Start a workout, then the middle action becomes Current workout,
359
-
Removed:
pointing at /workout, and the standalone Routine action is gone. *)
360
-
Removed:
let token = Option.get (csrf_token idle) in
361
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
362
-
Removed:
let home_page = body (get c "/") in
363
-
Removed:
let token = Option.get (csrf_token home_page) in
364
-
Removed:
let _ =
365
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
366
-
Removed:
in
367
-
Removed:
let logging = body (get c "/logbook") in
368
-
Removed:
Alcotest.(check bool)
369
-
Removed:
"swaps in the current workout action while logging" true
370
-
Removed:
(contains ~substring:">Current workout</a>" logging);
371
-
Removed:
Alcotest.(check bool)
372
-
Removed:
"drops the routine action while logging" false
373
-
Removed:
(contains ~substring:">Routine</a>" logging);
374
-
Removed:
(* Home shows an in-progress card that links to the current
375
-
Removed:
workout, rather than the recovery gate or the workout form. *)
376
-
Removed:
let home_logging = body (get c "/") in
377
-
Removed:
Alcotest.(check bool)
378
-
Removed:
"home shows the in-progress card while logging" true
379
-
Removed:
(contains ~substring:"in-progress-card" home_logging);
380
-
Removed:
Alcotest.(check bool)
381
-
Removed:
"the in-progress card links to the current workout" true
382
-
Removed:
(contains ~substring:"href=\"/workout\"" home_logging) );
383
-
Removed:
( "only the app shell carries the page class, so active state is single",
384
-
Removed:
`Quick,
385
-
Removed:
fun () ->
386
-
Removed:
(* Regression guard for the double-highlight bug: while a workout is
387
-
Removed:
in progress, the body must not carry a stale page- class. Only
388
-
Removed:
the app-shell div holds the page class, which the client replaces
389
-
Removed:
on navigation, so exactly one bottom-nav action can be active. *)
390
-
Removed:
let c = client () in
391
-
Removed:
let _ = sign_in_new c in
392
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
393
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
394
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
395
-
Removed:
let _ =
396
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
397
-
Removed:
in
398
-
Removed:
let logbook = body (get c "/logbook") in
399
-
Removed:
Alcotest.(check bool)
400
-
Removed:
"the body carries no page class" false
401
-
Removed:
(contains ~substring:"class=\"hito-app page-" logbook);
402
-
Removed:
Alcotest.(check bool)
403
-
Removed:
"the app shell marks the logbook page" true
404
-
Removed:
(contains ~substring:"app-shell page-logbook" logbook);
405
-
Removed:
Alcotest.(check bool)
406
-
Removed:
"the app shell does not also mark the workout page" false
407
-
Removed:
(contains ~substring:"app-shell page-workout" logbook) );
408
-
Removed:
( "auth pages omit the hamburger menu",
409
-
Removed:
`Quick,
410
-
Removed:
fun () ->
411
-
Removed:
let c = client () in
412
-
Removed:
let page = body (get c "/register") in
413
-
Removed:
Alcotest.(check bool)
414
-
Removed:
"no hamburger menu" false
415
-
Removed:
(contains ~substring:"class=\"menu\"" page) );
416
-
Removed:
( "the sign-in page renders a username field",
417
-
Removed:
`Quick,
418
-
Removed:
fun () ->
419
-
Removed:
let c = client () in
420
-
Removed:
let page = body (get c "/login") in
421
-
Removed:
Alcotest.(check bool)
422
-
Removed:
"has a username field" true
423
-
Removed:
(contains ~substring:"name=\"username\"" page) );
424
-
Removed:
( "auth pages omit navigation",
425
-
Removed:
`Quick,
426
-
Removed:
fun () ->
427
-
Removed:
let c = client () in
428
-
Removed:
let page = body (get c "/register") in
429
-
Removed:
Alcotest.(check bool)
430
-
Removed:
"no primary navigation" false
431
-
Removed:
(contains ~substring:"primary-nav" page);
432
-
Removed:
Alcotest.(check bool)
433
-
Removed:
"no bottom navigation" false
434
-
Removed:
(contains ~substring:"bottom-nav" page) );
435
-
Removed:
( "a form post without a CSRF token is refused",
436
-
Removed:
`Quick,
437
-
Removed:
fun () ->
438
-
Removed:
let c = client () in
439
-
Removed:
let _ = sign_in_new c in
440
-
Removed:
let response =
441
-
Removed:
post c "/routines/ideal/select" [ (* no dream.csrf *) ]
442
-
Removed:
in
443
-
Removed:
Alcotest.(check int)
444
-
Removed:
"redirects after the refused post" 303 (status response);
445
-
Removed:
let page = body (get c "/") in
446
-
Removed:
Alcotest.(check bool)
447
-
Removed:
"preserves the CSRF error as a toast" true
448
-
Removed:
(contains ~substring:"The submitted form is not valid." page) );
449
-
Removed:
( "registering a too-short username is refused with a message",
450
-
Removed:
`Quick,
451
-
Removed:
fun () ->
452
-
Removed:
let c = client () in
453
-
Removed:
let response = register c ~username:"abc" ~password:"heavyduty1" in
454
-
Removed:
Alcotest.(check int) "rejected" 400 (status response);
455
-
Removed:
Alcotest.(check bool)
456
-
Removed:
"explains the minimum length" true
457
-
Removed:
(contains ~substring:"at least 4 characters" (body response));
458
-
Removed:
Alcotest.(check bool)
459
-
Removed:
"announces the error" true
460
-
Removed:
(contains ~substring:"role=\"alert\"" (body response)) );
461
-
Removed:
( "registering a too-long username is refused with a message",
462
-
Removed:
`Quick,
463
-
Removed:
fun () ->
464
-
Removed:
let c = client () in
465
-
Removed:
let response =
466
-
Removed:
register c ~username:"abcdefghijklmnopqrstu"
467
-
Removed:
~password:"heavyduty1"
468
-
Removed:
in
469
-
Removed:
Alcotest.(check int) "rejected" 400 (status response);
470
-
Removed:
Alcotest.(check bool)
471
-
Removed:
"explains the maximum length" true
472
-
Removed:
(contains ~substring:"at most 20 characters" (body response)) );
473
-
Removed:
( "registering with a short password succeeds",
474
-
Removed:
`Quick,
475
-
Removed:
fun () ->
476
-
Removed:
let c = client () in
477
-
Removed:
let response = register c ~username:"lifter" ~password:"x" in
478
-
Removed:
Alcotest.(check int) "registered, redirected" 303 (status response);
479
-
Removed:
Alcotest.(check int)
480
-
Removed:
"authenticated overview" 200
481
-
Removed:
(status (get c "/")) );
482
-
Removed:
( "with registration closed the routes are gone and the link is hidden",
483
-
Removed:
`Quick,
484
-
Removed:
fun () ->
485
-
Removed:
(* The production default: [registration_open] unset. The
486
-
Removed:
[/register] routes are absent (404) and the sign-in page omits
487
-
Removed:
the Register link. *)
488
-
Removed:
let handlers = Handlers.make ~repo:(Memory_repo.create ()) () in
489
-
Removed:
let app =
490
-
Removed:
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers)
491
-
Removed:
|> fun h -> Dream.set_secret "test-secret-value" h
492
-
Removed:
in
493
-
Removed:
let c = { app; jar = [] } in
494
-
Removed:
Alcotest.(check int)
495
-
Removed:
"GET /register is not found" 404
496
-
Removed:
(status (get c "/register"));
497
-
Removed:
let login = body (get c "/login") in
498
-
Removed:
Alcotest.(check bool)
499
-
Removed:
"no Register link on sign-in" false
500
-
Removed:
(contains ~substring:"href=\"/register\"" login) );
501
-
Removed:
] );
502
-
Removed:
( "web.flow",
503
-
Removed:
[
504
-
Removed:
( "a state-changing POST redirects and carries a flash to the GET",
505
-
Removed:
`Quick,
506
-
Removed:
fun () ->
507
-
Removed:
(* POST-redirect-GET: selecting a routine answers 303, and the
508
-
Removed:
success message rides a one-shot flash onto the redirected page
509
-
Removed:
rather than rendering inline on the POST response. *)
510
-
Removed:
let c = client () in
511
-
Removed:
let _ = sign_in_new c in
512
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
513
-
Removed:
let selection =
514
-
Removed:
post c "/routines/ideal/select" [ ("dream.csrf", token) ]
515
-
Removed:
in
516
-
Removed:
Alcotest.(check int) "selection redirects" 303 (status selection);
517
-
Removed:
Alcotest.(check bool)
518
-
Removed:
"the POST body renders no page" true
519
-
Removed:
(String.length (body selection) = 0
520
-
Removed:
|| not (contains ~substring:"<h1" (body selection)));
521
-
Removed:
let home_page = body (get c "/") in
522
-
Removed:
Alcotest.(check bool)
523
-
Removed:
"the redirected GET shows the flash toast" true
524
-
Removed:
(contains ~substring:"data-hito-toast" home_page
525
-
Removed:
&& contains ~substring:"Routine selected." home_page);
526
-
Removed:
let home_again = body (get c "/") in
527
-
Removed:
Alcotest.(check bool)
528
-
Removed:
"the flash is one-shot and gone on reload" false
529
-
Removed:
(contains ~substring:"Routine selected." home_again) );
530
-
Removed:
( "a signed-in trainee starts a workout and sees the exercise buttons",
531
-
Removed:
`Quick,
532
-
Removed:
fun () ->
533
-
Removed:
let c = client () in
534
-
Removed:
let _ = sign_in_new c in
535
-
Removed:
let select_page = body (get c "/") in
536
-
Removed:
Alcotest.(check bool)
537
-
Removed:
"enhances authenticated overview navigation" true
538
-
Removed:
(contains ~substring:"data-hito-app-link" select_page
539
-
Removed:
&& contains ~substring:"data-hito-app-content" select_page
540
-
Removed:
&& contains ~substring:"data-hito-page-title" select_page
541
-
Removed:
&& contains ~substring:"/assets/workout-client.js" select_page);
542
-
Removed:
Alcotest.(check bool)
543
-
Removed:
"marks routine selection for in-place submission" true
544
-
Removed:
(contains ~substring:"data-hito-app-form" select_page);
545
-
Removed:
let token = Option.get (csrf_token select_page) in
546
-
Removed:
let selection =
547
-
Removed:
post c "/routines/ideal/select" [ ("dream.csrf", token) ]
548
-
Removed:
in
549
-
Removed:
Alcotest.(check int) "routine selected" 303 (status selection);
550
-
Removed:
let home_page = body (get c "/") in
551
-
Removed:
let token = Option.get (csrf_token home_page) in
552
-
Removed:
let started =
553
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
554
-
Removed:
in
555
-
Removed:
Alcotest.(check int) "workout started" 303 (status started);
556
-
Removed:
let workout_page = body (get c "/workout") in
557
-
Removed:
Alcotest.(check bool)
558
-
Removed:
"renders Day 1 as the page heading" true
559
-
Removed:
(contains ~substring:"<h1>Day 1</h1>" workout_page);
560
-
Removed:
Alcotest.(check bool)
561
-
Removed:
"renders an exercise dropdown selector" true
562
-
Removed:
(contains ~substring:"exercise-select" workout_page);
563
-
Removed:
(* The exercise label stays for screen readers but is visually
564
-
Removed:
hidden, so the dropdown reads without a redundant caption. *)
565
-
Removed:
Alcotest.(check bool)
566
-
Removed:
"keeps the exercise label for the select" true
567
-
Removed:
(contains ~substring:"for=\"exercise-choice\"" workout_page);
568
-
Removed:
Alcotest.(check bool)
569
-
Removed:
"hides the exercise label visually with sr-only" true
570
-
Removed:
(contains ~substring:"sr-only" workout_page);
571
-
Removed:
Alcotest.(check bool)
572
-
Removed:
"does not use the old tab strip class" false
573
-
Removed:
(contains ~substring:"slot-tab" workout_page);
574
-
Removed:
Alcotest.(check bool)
575
-
Removed:
"does not use the old button group class" false
576
-
Removed:
(contains ~substring:"exercise-group" workout_page);
577
-
Removed:
Alcotest.(check bool)
578
-
Removed:
"marks the exercise select for enhancement" true
579
-
Removed:
(contains ~substring:"data-hito-exercise-select" workout_page);
580
-
Removed:
Alcotest.(check bool)
581
-
Removed:
"preselects the active slot option" true
582
-
Removed:
(contains ~substring:"selected" workout_page);
583
-
Removed:
Alcotest.(check bool)
584
-
Removed:
"offers a later slot as a select option" true
585
-
Removed:
(contains ~substring:"value=\"1\"" workout_page);
586
-
Removed:
Alcotest.(check bool)
587
-
Removed:
"opens on the first slot's record form" true
588
-
Removed:
(contains ~substring:"/workout/slots/0" workout_page);
589
-
Removed:
Alcotest.(check bool)
590
-
Removed:
"marks the logging fieldset without a boulder top border" true
591
-
Removed:
(contains ~substring:"logging-fieldset" workout_page);
592
-
Removed:
Alcotest.(check bool)
593
-
Removed:
"marks the current workout as progressively enhanced" true
594
-
Removed:
(contains ~substring:"data-hito-workout" workout_page);
595
-
Removed:
Alcotest.(check bool)
596
-
Removed:
"marks the replaceable workout content" true
597
-
Removed:
(contains ~substring:"data-hito-workout-content" workout_page);
598
-
Removed:
Alcotest.(check bool)
599
-
Removed:
"marks the exercise form for enhancement" true
600
-
Removed:
(contains ~substring:"data-hito-exercise-form" workout_page);
601
-
Removed:
Alcotest.(check bool)
602
-
Removed:
"marks record forms while retaining their CSRF field" true
603
-
Removed:
(contains ~substring:"data-hito-workout-form" workout_page
604
-
Removed:
&& contains ~substring:"dream.csrf" workout_page);
605
-
Removed:
Alcotest.(check bool)
606
-
Removed:
"loads only the workout client" true
607
-
Removed:
(contains ~substring:"/assets/workout-client.js" workout_page);
608
-
Removed:
let asset = get c "/assets/workout-client.js" in
609
-
Removed:
Alcotest.(check int) "serves the workout client" 200 (status asset);
610
-
Removed:
Alcotest.(check bool)
611
-
Removed:
"client has JavaScript content type" true
612
-
Removed:
(List.mem "application/javascript; charset=utf-8"
613
-
Removed:
(Dream.headers asset "Content-Type")) );
614
-
Removed:
( "the routine page lists workouts in a basic table",
615
-
Removed:
`Quick,
616
-
Removed:
fun () ->
617
-
Removed:
let c = client () in
618
-
Removed:
let _ = sign_in_new c in
619
-
Removed:
let selection_page = body (get c "/") in
620
-
Removed:
let token = Option.get (csrf_token selection_page) in
621
-
Removed:
let selection =
622
-
Removed:
post c "/routines/ideal/select" [ ("dream.csrf", token) ]
623
-
Removed:
in
624
-
Removed:
Alcotest.(check int) "routine selected" 303 (status selection);
625
-
Removed:
let page = body (get c "/routine") in
626
-
Removed:
(* The masthead profile menu is a <details>; scope the accordion
627
-
Removed:
check to the main content so it reads the routine, not the
628
-
Removed:
masthead. *)
629
-
Removed:
let content =
630
-
Removed:
match index_from ~needle:"id=\"main-content\"" page 0 with
631
-
Removed:
| Some start -> String.sub page start (String.length page - start)
632
-
Removed:
| None -> page
633
-
Removed:
in
634
-
Removed:
Alcotest.(check bool)
635
-
Removed:
"uses a table" true
636
-
Removed:
(contains ~substring:"<table" page);
637
-
Removed:
Alcotest.(check bool)
638
-
Removed:
"no longer uses details accordions" false
639
-
Removed:
(contains ~substring:"<details" content);
640
-
Removed:
Alcotest.(check bool)
641
-
Removed:
"heads the workout column" true
642
-
Removed:
(contains ~substring:"Workout" page);
643
-
Removed:
Alcotest.(check bool)
644
-
Removed:
"names Day 1 as a row" true
645
-
Removed:
(contains ~substring:"Day 1" page);
646
-
Removed:
Alcotest.(check bool)
647
-
Removed:
"includes prescription" true
648
-
Removed:
(contains ~substring:"Dumbbell Flyes into Incline Presses" page);
649
-
Removed:
Alcotest.(check bool)
650
-
Removed:
"heads an exercise column" true
651
-
Removed:
(contains ~substring:"Exercise" page);
652
-
Removed:
Alcotest.(check bool)
653
-
Removed:
"heads a reps column" true
654
-
Removed:
(contains ~substring:"Reps" page);
655
-
Removed:
Alcotest.(check bool)
656
-
Removed:
"gives each exercise its own sub-row" true
657
-
Removed:
(contains ~substring:"rowgroup" page);
658
-
Removed:
Alcotest.(check bool)
659
-
Removed:
"drops the stimuli count column" false
660
-
Removed:
(contains ~substring:"Stimuli" page);
661
-
Removed:
Alcotest.(check bool)
662
-
Removed:
"shows primary navigation" true
663
-
Removed:
(contains ~substring:"primary-nav" page);
664
-
Removed:
Alcotest.(check bool)
665
-
Removed:
"shows bottom navigation" true
666
-
Removed:
(contains ~substring:"bottom-nav" page) );
667
-
Removed:
( "effort forms lay the whole fieldset out in one responsive grid",
668
-
Removed:
`Quick,
669
-
Removed:
fun () ->
670
-
Removed:
let c = client () in
671
-
Removed:
let _ = sign_in_new c in
672
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
673
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
674
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
675
-
Removed:
let _ =
676
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
677
-
Removed:
in
678
-
Removed:
let page = body (get c "/workout") in
679
-
Removed:
Alcotest.(check bool)
680
-
Removed:
"wraps the fieldset in one shared grid" true
681
-
Removed:
(contains ~substring:"class=\"logging-grid\"" page);
682
-
Removed:
Alcotest.(check bool)
683
-
Removed:
"groups a movement's cells without a nested grid" true
684
-
Removed:
(contains ~substring:"class=\"movement\"" page);
685
-
Removed:
Alcotest.(check bool)
686
-
Removed:
"no longer uses a per-row exercise-fields grid" false
687
-
Removed:
(contains ~substring:"class=\"exercise-fields\"" page);
688
-
Removed:
Alcotest.(check bool)
689
-
Removed:
"still labels load" true
690
-
Removed:
(contains ~substring:"Load (kg)" page);
691
-
Removed:
Alcotest.(check bool)
692
-
Removed:
"hints the load field with its unit" true
693
-
Removed:
(contains ~substring:"placeholder=\"kg\"" page);
694
-
Removed:
Alcotest.(check bool)
695
-
Removed:
"hints the reps field" true
696
-
Removed:
(contains ~substring:"placeholder=\"reps\"" page);
697
-
Removed:
Alcotest.(check bool)
698
-
Removed:
"drops the redundant fieldset legend" false
699
-
Removed:
(contains ~substring:"<legend>" page) );
700
-
Removed:
( "a recorded slot can be corrected, replacing it rather than adding \
701
-
Removed:
volume",
702
-
Removed:
`Quick,
703
-
Removed:
fun () ->
704
-
Removed:
let c = client () in
705
-
Removed:
let _ = sign_in_new c in
706
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
707
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
708
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
709
-
Removed:
let _ =
710
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
711
-
Removed:
in
712
-
Removed:
(* Slot 1 is the single Laterals set. Open its tab, record it, then
713
-
Removed:
correct it — one slot at a time, addressed by [?slot=]. *)
714
-
Removed:
let page = body (get c "/workout?slot=1") in
715
-
Removed:
let token = Option.get (csrf_token page) in
716
-
Removed:
let recorded =
717
-
Removed:
post c "/workout/slots/1"
718
-
Removed:
[
719
-
Removed:
("dream.csrf", token);
720
-
Removed:
("load", "12");
721
-
Removed:
("reps", "8");
722
-
Removed:
("extension", "");
723
-
Removed:
]
724
-
Removed:
in
725
-
Removed:
Alcotest.(check int) "recorded" 303 (status recorded);
726
-
Removed:
(* The default view now opens on slot 0, not the recorded slot. *)
727
-
Removed:
let default_page = body (get c "/workout") in
728
-
Removed:
Alcotest.(check bool)
729
-
Removed:
"the default view does not show the recorded slot's edit form"
730
-
Removed:
false
731
-
Removed:
(contains ~substring:"/workout/slots/1/edit" default_page);
732
-
Removed:
Alcotest.(check bool)
733
-
Removed:
"counts one filled slot" true
734
-
Removed:
(contains ~substring:"1 of 4 recorded" default_page);
735
-
Removed:
(* The recorded slot's tab shows its correction form. *)
736
-
Removed:
let page = body (get c "/workout?slot=1") in
737
-
Removed:
Alcotest.(check bool)
738
-
Removed:
"shows a correction form for the slot" true
739
-
Removed:
(contains ~substring:"/workout/slots/1/edit" page);
740
-
Removed:
let token = Option.get (csrf_token page) in
741
-
Removed:
let corrected =
742
-
Removed:
post c "/workout/slots/1/edit"
743
-
Removed:
[
744
-
Removed:
("dream.csrf", token);
745
-
Removed:
("load", "16");
746
-
Removed:
("reps", "6");
747
-
Removed:
("extension", "");
748
-
Removed:
]
749
-
Removed:
in
750
-
Removed:
Alcotest.(check int) "corrected" 303 (status corrected);
751
-
Removed:
let page = body (get c "/workout?slot=1") in
752
-
Removed:
Alcotest.(check bool)
753
-
Removed:
"still one filled slot, not two" true
754
-
Removed:
(contains ~substring:"1 of 4 recorded" page);
755
-
Removed:
Alcotest.(check bool)
756
-
Removed:
"shows the corrected load" true
757
-
Removed:
(contains ~substring:"16" page) );
758
-
Removed:
( "a saved workout's slot can be corrected from the logbook view",
759
-
Removed:
`Quick,
760
-
Removed:
fun () ->
761
-
Removed:
let c = client () in
762
-
Removed:
let _ = sign_in_new c in
763
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
764
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
765
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
766
-
Removed:
let _ =
767
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
768
-
Removed:
in
769
-
Removed:
let token =
770
-
Removed:
Option.get (csrf_token (body (get c "/workout?slot=1")))
771
-
Removed:
in
772
-
Removed:
let _ =
773
-
Removed:
post c "/workout/slots/1"
774
-
Removed:
[
775
-
Removed:
("dream.csrf", token);
776
-
Removed:
("load", "12");
777
-
Removed:
("reps", "8");
778
-
Removed:
("extension", "");
779
-
Removed:
]
780
-
Removed:
in
781
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
782
-
Removed:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
783
-
Removed:
(* The one saved workout is w1 for this trainee. Its recorded slot is
784
-
Removed:
reached through the same [?slot=] state. *)
785
-
Removed:
let record_page = body (get c "/logbook/w1?slot=1") in
786
-
Removed:
Alcotest.(check bool)
787
-
Removed:
"renders an exercise dropdown for the saved workout" true
788
-
Removed:
(contains ~substring:"exercise-select" record_page);
789
-
Removed:
Alcotest.(check bool)
790
-
Removed:
"does not use the old tab strip class" false
791
-
Removed:
(contains ~substring:"slot-tab" record_page);
792
-
Removed:
Alcotest.(check bool)
793
-
Removed:
"does not enhance the saved exercise selector" false
794
-
Removed:
(contains ~substring:"data-hito-exercise-form" record_page);
795
-
Removed:
Alcotest.(check bool)
796
-
Removed:
"does not mark saved history as replaceable workout content" false
797
-
Removed:
(contains ~substring:"data-hito-workout-content" record_page);
798
-
Removed:
Alcotest.(check bool)
799
-
Removed:
"marks saved history as generic app content" true
800
-
Removed:
(contains ~substring:"data-hito-app-content" record_page);
801
-
Removed:
Alcotest.(check bool)
802
-
Removed:
"loads the app client for saved history navigation" true
803
-
Removed:
(contains ~substring:"/assets/workout-client.js" record_page);
804
-
Removed:
Alcotest.(check bool)
805
-
Removed:
"enhances saved-slot corrections through the generic form path"
806
-
Removed:
true
807
-
Removed:
(contains ~substring:"data-hito-app-form" record_page);
808
-
Removed:
Alcotest.(check bool)
809
-
Removed:
"offers a saved-slot correction form" true
810
-
Removed:
(contains ~substring:"/logbook/w1/slots/1/edit" record_page);
811
-
Removed:
let token = Option.get (csrf_token record_page) in
812
-
Removed:
let corrected =
813
-
Removed:
post c "/logbook/w1/slots/1/edit"
814
-
Removed:
[
815
-
Removed:
("dream.csrf", token);
816
-
Removed:
("load", "20");
817
-
Removed:
("reps", "7");
818
-
Removed:
("extension", "");
819
-
Removed:
]
820
-
Removed:
in
821
-
Removed:
Alcotest.(check int) "corrected" 303 (status corrected);
822
-
Removed:
let record_page = body (get c "/logbook/w1?slot=1") in
823
-
Removed:
Alcotest.(check bool)
824
-
Removed:
"still one filled slot" true
825
-
Removed:
(contains ~substring:"1 of 4 recorded" record_page);
826
-
Removed:
Alcotest.(check bool)
827
-
Removed:
"shows the corrected load" true
828
-
Removed:
(contains ~substring:"20" record_page) );
829
-
Removed:
( "invalid workout fields identify and describe their errors",
830
-
Removed:
`Quick,
831
-
Removed:
fun () ->
832
-
Removed:
let c = client () in
833
-
Removed:
let _ = sign_in_new c in
834
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
835
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
836
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
837
-
Removed:
let _ =
838
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
839
-
Removed:
in
840
-
Removed:
let page = body (get c "/workout") in
841
-
Removed:
let token = Option.get (csrf_token page) in
842
-
Removed:
let invalid =
843
-
Removed:
post c "/workout/slots/0"
844
-
Removed:
[
845
-
Removed:
("dream.csrf", token);
846
-
Removed:
("iso_load", "not-a-number");
847
-
Removed:
("iso_reps", "8");
848
-
Removed:
("comp_load", "40");
849
-
Removed:
("comp_reps", "6");
850
-
Removed:
("extension", "");
851
-
Removed:
]
852
-
Removed:
in
853
-
Removed:
Alcotest.(check int) "invalid field redirects" 303 (status invalid);
854
-
Removed:
let invalid_page = body (get c "/workout") in
855
-
Removed:
Alcotest.(check bool)
856
-
Removed:
"preserves a useful invalid-input error as a toast" true
857
-
Removed:
(contains ~substring:"data-hito-toast" invalid_page
858
-
Removed:
&& contains ~substring:"role=\"status\"" invalid_page
859
-
Removed:
&& contains ~substring:"Enter a valid number." invalid_page
860
-
Removed:
&& not (contains ~substring:"error." invalid_page)) );
861
-
Removed:
( "the workout view shows one slot at a time and defaults to the first \
862
-
Removed:
incomplete slot",
863
-
Removed:
`Quick,
864
-
Removed:
fun () ->
865
-
Removed:
let c = client () in
866
-
Removed:
let _ = sign_in_new c in
867
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
868
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
869
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
870
-
Removed:
let _ =
871
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
872
-
Removed:
in
873
-
Removed:
(* Nothing recorded yet: the default view opens on slot 0 and shows
874
-
Removed:
only slot 0's record form, not slot 1's. *)
875
-
Removed:
let page = body (get c "/workout") in
876
-
Removed:
Alcotest.(check bool)
877
-
Removed:
"shows the first slot's form" true
878
-
Removed:
(contains ~substring:"action=\"/workout/slots/0\"" page);
879
-
Removed:
Alcotest.(check bool)
880
-
Removed:
"does not also show a later slot's form" false
881
-
Removed:
(contains ~substring:"action=\"/workout/slots/1\"" page);
882
-
Removed:
(* Record slot 0. The default now moves to the next incomplete slot,
883
-
Removed:
slot 1. *)
884
-
Removed:
let token = Option.get (csrf_token page) in
885
-
Removed:
let recorded =
886
-
Removed:
post c "/workout/slots/0"
887
-
Removed:
[
888
-
Removed:
("dream.csrf", token);
889
-
Removed:
("iso_load", "10");
890
-
Removed:
("iso_reps", "8");
891
-
Removed:
("comp_load", "40");
892
-
Removed:
("comp_reps", "6");
893
-
Removed:
("extension", "");
894
-
Removed:
]
895
-
Removed:
in
896
-
Removed:
Alcotest.(check int) "recorded slot 0" 303 (status recorded);
897
-
Removed:
let page = body (get c "/workout") in
898
-
Removed:
Alcotest.(check bool)
899
-
Removed:
"default now opens on slot 1's record form" true
900
-
Removed:
(contains ~substring:"action=\"/workout/slots/1\"" page) );
901
-
Removed:
( "signing out returns the trainee to the sign-in page",
902
-
Removed:
`Quick,
903
-
Removed:
fun () ->
904
-
Removed:
let c = client () in
905
-
Removed:
let _ = sign_in_new c in
906
-
Removed:
let page = body (get c "/") in
907
-
Removed:
let token = Option.get (csrf_token page) in
908
-
Removed:
let out = post c "/logout" [ ("dream.csrf", token) ] in
909
-
Removed:
Alcotest.(check int) "logout redirect" 303 (status out);
910
-
Removed:
let after = get c "/" in
911
-
Removed:
Alcotest.(check int) "overview now redirects" 303 (status after) );
912
-
Removed:
( "the current workout offers a CSRF-protected cancel control",
913
-
Removed:
`Quick,
914
-
Removed:
fun () ->
915
-
Removed:
let c = client () in
916
-
Removed:
let _ = sign_in_new c in
917
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
918
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
919
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
920
-
Removed:
let _ =
921
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
922
-
Removed:
in
923
-
Removed:
let page = body (get c "/workout") in
924
-
Removed:
Alcotest.(check bool)
925
-
Removed:
"posts to the cancel route" true
926
-
Removed:
(contains ~substring:"action=\"/workout/cancel\"" page);
927
-
Removed:
Alcotest.(check bool)
928
-
Removed:
"labels the control Cancel logging" true
929
-
Removed:
(contains ~substring:"Cancel logging" page);
930
-
Removed:
Alcotest.(check bool)
931
-
Removed:
"renders it as a secondary submit" true
932
-
Removed:
(contains ~substring:"class=\"secondary\"" page);
933
-
Removed:
Alcotest.(check bool)
934
-
Removed:
"carries a confirmation modal for cancellation" true
935
-
Removed:
(contains ~substring:"data-hito-confirm-modal" page);
936
-
Removed:
Alcotest.(check bool)
937
-
Removed:
"marks the cancel form for confirmation" true
938
-
Removed:
(contains ~substring:"data-hito-confirm-form" page);
939
-
Removed:
Alcotest.(check bool)
940
-
Removed:
"uses native navigation after confirmation" true
941
-
Removed:
(contains ~substring:"data-hito-cancel-form" page);
942
-
Removed:
(* The modal copy must match the discard semantics: cancelling
943
-
Removed:
leaves no record. It must not promise a saved, editable entry,
944
-
Removed:
which would tell the user the workout was not really cancelled. *)
945
-
Removed:
Alcotest.(check bool)
946
-
Removed:
"warns that the workout is discarded" true
947
-
Removed:
(contains ~substring:"discarded and leaves no record" page);
948
-
Removed:
Alcotest.(check bool)
949
-
Removed:
"does not promise a saved editable entry" false
950
-
Removed:
(contains ~substring:"saved as a partial logbook entry" page) );
951
-
Removed:
( "the current workout shows a sticky timer bar",
952
-
Removed:
`Quick,
953
-
Removed:
fun () ->
954
-
Removed:
let c = client () in
955
-
Removed:
let _ = sign_in_new c in
956
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
957
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
958
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
959
-
Removed:
let _ =
960
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
961
-
Removed:
in
962
-
Removed:
let page = body (get c "/workout") in
963
-
Removed:
Alcotest.(check bool)
964
-
Removed:
"renders the timer bar while logging" true
965
-
Removed:
(contains ~substring:"data-hito-workout-timer" page);
966
-
Removed:
Alcotest.(check bool)
967
-
Removed:
"carries the workout start epoch" true
968
-
Removed:
(contains ~substring:"data-started=" page);
969
-
Removed:
let client_script = body (get c "/assets/workout-client.js") in
970
-
Removed:
Alcotest.(check bool)
971
-
Removed:
"serves the DOM-ready timer startup hook" true
972
-
Removed:
(contains ~substring:"DOMContentLoaded" client_script) );
973
-
Removed:
( "cancelling a workout without a CSRF token leaves the view cleanly",
974
-
Removed:
`Quick,
975
-
Removed:
fun () ->
976
-
Removed:
let c = client () in
977
-
Removed:
let _ = sign_in_new c in
978
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
979
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
980
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
981
-
Removed:
let _ =
982
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
983
-
Removed:
in
984
-
Removed:
let response = post c "/workout/cancel" [ (* no dream.csrf *) ] in
985
-
Removed:
(* No invalid-form response: cancelling always navigates away to
986
-
Removed:
Home. A forged or missing token skips the discard, so the
987
-
Removed:
workout remains in progress. *)
988
-
Removed:
Alcotest.(check int)
989
-
Removed:
"navigates away without an error page" 303 (status response);
990
-
Removed:
Alcotest.(check bool)
991
-
Removed:
"returns Home" true
992
-
Removed:
(List.mem "/" (Dream.headers response "Location"));
993
-
Removed:
Alcotest.(check int)
994
-
Removed:
"workout still in progress" 200
995
-
Removed:
(status (get c "/workout")) );
996
-
Removed:
( "cancelling after recording a set discards the workout",
997
-
Removed:
`Quick,
998
-
Removed:
fun () ->
999
-
Removed:
let c = client () in
1000
-
Removed:
let _ = sign_in_new c in
1001
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1002
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1003
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1004
-
Removed:
let _ =
1005
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1006
-
Removed:
in
1007
-
Removed:
(* Record the single Laterals set at slot 1. *)
1008
-
Removed:
let token =
1009
-
Removed:
Option.get (csrf_token (body (get c "/workout?slot=1")))
1010
-
Removed:
in
1011
-
Removed:
let recorded =
1012
-
Removed:
post c "/workout/slots/1"
1013
-
Removed:
[
1014
-
Removed:
("dream.csrf", token);
1015
-
Removed:
("load", "12");
1016
-
Removed:
("reps", "8");
1017
-
Removed:
("extension", "");
1018
-
Removed:
]
1019
-
Removed:
in
1020
-
Removed:
Alcotest.(check int) "recorded a set" 303 (status recorded);
1021
-
Removed:
(* Cancel with a valid token. It discards the in-progress workout
1022
-
Removed:
and returns Home. *)
1023
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1024
-
Removed:
let cancelled =
1025
-
Removed:
post c "/workout/cancel" [ ("dream.csrf", token) ]
1026
-
Removed:
in
1027
-
Removed:
Alcotest.(check int) "cancel returns Home" 303 (status cancelled);
1028
-
Removed:
Alcotest.(check bool)
1029
-
Removed:
"returns Home, not the logbook" true
1030
-
Removed:
(List.mem "/" (Dream.headers cancelled "Location"));
1031
-
Removed:
(* No current workout: the workout view redirects away. *)
1032
-
Removed:
Alcotest.(check int)
1033
-
Removed:
"no workout in progress" 303
1034
-
Removed:
(status (get c "/workout"));
1035
-
Removed:
(* The workout left no record: an abandoned session is not
1036
-
Removed:
evidence, so the logbook holds nothing and no record opens. *)
1037
-
Removed:
let logbook_page = body (get c "/logbook") in
1038
-
Removed:
Alcotest.(check bool)
1039
-
Removed:
"logbook shows no saved workout" false
1040
-
Removed:
(contains ~substring:"/logbook/w1" logbook_page);
1041
-
Removed:
Alcotest.(check int)
1042
-
Removed:
"the discarded workout has no record" 404
1043
-
Removed:
(status (get c "/logbook/w1")) );
1044
-
Removed:
( "cancellation returns Home after discarding the workout",
1045
-
Removed:
`Quick,
1046
-
Removed:
fun () ->
1047
-
Removed:
let c = client () in
1048
-
Removed:
let _ = sign_in_new c in
1049
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1050
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1051
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1052
-
Removed:
let _ =
1053
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1054
-
Removed:
in
1055
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1056
-
Removed:
let cancelled =
1057
-
Removed:
post c "/workout/cancel" [ ("dream.csrf", token) ]
1058
-
Removed:
in
1059
-
Removed:
Alcotest.(check bool)
1060
-
Removed:
"cancel redirects to Home" true
1061
-
Removed:
(List.mem "/" (Dream.headers cancelled "Location"));
1062
-
Removed:
let home_page = body (get c "/") in
1063
-
Removed:
Alcotest.(check bool)
1064
-
Removed:
"Home has no current workout card" false
1065
-
Removed:
(contains ~substring:"Workout in progress" home_page) );
1066
-
Removed:
( "the saved logbook view does not offer a cancel control",
1067
-
Removed:
`Quick,
1068
-
Removed:
fun () ->
1069
-
Removed:
let c = client () in
1070
-
Removed:
let _ = sign_in_new c in
1071
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1072
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1073
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1074
-
Removed:
let _ =
1075
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1076
-
Removed:
in
1077
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1078
-
Removed:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
1079
-
Removed:
let record_page = body (get c "/logbook/w1") in
1080
-
Removed:
Alcotest.(check bool)
1081
-
Removed:
"the saved view has no cancel route" false
1082
-
Removed:
(contains ~substring:"/workout/cancel" record_page);
1083
-
Removed:
Alcotest.(check bool)
1084
-
Removed:
"the saved view has no cancel label" false
1085
-
Removed:
(contains ~substring:"Cancel logging" record_page);
1086
-
Removed:
Alcotest.(check bool)
1087
-
Removed:
"shows primary navigation" true
1088
-
Removed:
(contains ~substring:"primary-nav" record_page);
1089
-
Removed:
Alcotest.(check bool)
1090
-
Removed:
"shows bottom navigation" true
1091
-
Removed:
(contains ~substring:"bottom-nav" record_page) );
1092
-
Removed:
( "the logbook opens sequential feedback in a modal",
1093
-
Removed:
`Quick,
1094
-
Removed:
fun () ->
1095
-
Removed:
let c = client () in
1096
-
Removed:
let _ = sign_in_new c in
1097
-
Removed:
let logbook_page = body (get c "/logbook") in
1098
-
Removed:
Alcotest.(check bool)
1099
-
Removed:
"still renders the feedback form" true
1100
-
Removed:
(contains ~substring:"feedback-form" logbook_page);
1101
-
Removed:
Alcotest.(check bool)
1102
-
Removed:
"offers a feedback modal trigger" true
1103
-
Removed:
(contains ~substring:"data-hito-feedback-open" logbook_page
1104
-
Removed:
&& contains ~substring:"Record feedback" logbook_page);
1105
-
Removed:
Alcotest.(check bool)
1106
-
Removed:
"renders a native feedback modal" true
1107
-
Removed:
(contains ~substring:"data-hito-feedback-modal" logbook_page);
1108
-
Removed:
Alcotest.(check bool)
1109
-
Removed:
"renders only the first factor initially" true
1110
-
Removed:
(contains ~substring:">Sleep</h3>" logbook_page
1111
-
Removed:
&& contains ~substring:"1 — very poor</span>" logbook_page
1112
-
Removed:
&& not (contains ~substring:">Appetite</h3>" logbook_page));
1113
-
Removed:
Alcotest.(check bool)
1114
-
Removed:
"renders a progress bar and factor skip" true
1115
-
Removed:
(contains ~substring:"<progress" logbook_page
1116
-
Removed:
&& contains ~substring:"1 of 5 factors" logbook_page
1117
-
Removed:
&& contains ~substring:"Skip this factor" logbook_page
1118
-
Removed:
&& contains ~substring:"Skip all feedback" logbook_page);
1119
-
Removed:
Alcotest.(check bool)
1120
-
Removed:
"does not render subjective selects" false
1121
-
Removed:
(contains ~substring:"<select" logbook_page) );
1122
-
Removed:
( "sequential feedback preserves answers and skips factors",
1123
-
Removed:
`Quick,
1124
-
Removed:
fun () ->
1125
-
Removed:
let c = client () in
1126
-
Removed:
let _ = sign_in_new c in
1127
-
Removed:
let start = body (get c "/logbook?feedback=start") in
1128
-
Removed:
Alcotest.(check bool)
1129
-
Removed:
"start opens the modal" true
1130
-
Removed:
(contains ~substring:"feedback-modal" start
1131
-
Removed:
&& contains ~substring:">Sleep</h3>" start);
1132
-
Removed:
let token = Option.get (csrf_token start) in
1133
-
Removed:
let next =
1134
-
Removed:
post c "/feedback"
1135
-
Removed:
[
1136
-
Removed:
("dream.csrf", token);
1137
-
Removed:
("step", "0");
1138
-
Removed:
("action", "next");
1139
-
Removed:
("choice", "1");
1140
-
Removed:
]
1141
-
Removed:
in
1142
-
Removed:
Alcotest.(check int) "first factor redirects" 303 (status next);
1143
-
Removed:
let appetite = body (get c "/logbook") in
1144
-
Removed:
Alcotest.(check bool)
1145
-
Removed:
"moves to appetite with progress" true
1146
-
Removed:
(contains ~substring:">Appetite</h3>" appetite
1147
-
Removed:
&& contains ~substring:"2 of 5 factors" appetite);
1148
-
Removed:
let token = Option.get (csrf_token appetite) in
1149
-
Removed:
let skipped =
1150
-
Removed:
post c "/feedback"
1151
-
Removed:
[ ("dream.csrf", token); ("step", "1"); ("action", "skip") ]
1152
-
Removed:
in
1153
-
Removed:
Alcotest.(check int) "factor skip redirects" 303 (status skipped);
1154
-
Removed:
let readiness = body (get c "/logbook") in
1155
-
Removed:
Alcotest.(check bool)
1156
-
Removed:
"skipping moves to the next factor" true
1157
-
Removed:
(contains ~substring:">Readiness</h3>" readiness
1158
-
Removed:
&& contains ~substring:"3 of 5 factors" readiness);
1159
-
Removed:
let token = Option.get (csrf_token readiness) in
1160
-
Removed:
ignore
1161
-
Removed:
(post c "/feedback"
1162
-
Removed:
[
1163
-
Removed:
("dream.csrf", token);
1164
-
Removed:
("step", "2");
1165
-
Removed:
("action", "next");
1166
-
Removed:
("choice", "3");
1167
-
Removed:
]);
1168
-
Removed:
let motivation = body (get c "/logbook") in
1169
-
Removed:
let token = Option.get (csrf_token motivation) in
1170
-
Removed:
ignore
1171
-
Removed:
(post c "/feedback"
1172
-
Removed:
[
1173
-
Removed:
("dream.csrf", token);
1174
-
Removed:
("step", "3");
1175
-
Removed:
("action", "next");
1176
-
Removed:
("choice", "5");
1177
-
Removed:
]);
1178
-
Removed:
let difficulty = body (get c "/logbook") in
1179
-
Removed:
let token = Option.get (csrf_token difficulty) in
1180
-
Removed:
let notes =
1181
-
Removed:
post c "/feedback"
1182
-
Removed:
[ ("dream.csrf", token); ("step", "4"); ("action", "skip") ]
1183
-
Removed:
in
1184
-
Removed:
Alcotest.(check int) "last factor skip redirects" 303 (status notes);
1185
-
Removed:
let notes_page = body (get c "/logbook") in
1186
-
Removed:
Alcotest.(check bool)
1187
-
Removed:
"shows optional observations after factors" true
1188
-
Removed:
(contains ~substring:">Anything else?</h3>" notes_page
1189
-
Removed:
&& contains ~substring:"5 of 5 factors" notes_page);
1190
-
Removed:
let token = Option.get (csrf_token notes_page) in
1191
-
Removed:
let saved =
1192
-
Removed:
post c "/feedback"
1193
-
Removed:
[
1194
-
Removed:
("dream.csrf", token);
1195
-
Removed:
("step", "5");
1196
-
Removed:
("action", "save");
1197
-
Removed:
("pain", "true");
1198
-
Removed:
]
1199
-
Removed:
in
1200
-
Removed:
Alcotest.(check int) "final feedback redirects" 303 (status saved);
1201
-
Removed:
let logbook_page = body (get c "/logbook") in
1202
-
Removed:
Alcotest.(check bool)
1203
-
Removed:
"stores answered factors and final observations" true
1204
-
Removed:
(contains ~substring:"Sleep 1 (very poor)" logbook_page
1205
-
Removed:
&& contains ~substring:"Readiness 3" logbook_page
1206
-
Removed:
&& contains ~substring:"Motivation 5 (very good)" logbook_page
1207
-
Removed:
&& contains ~substring:"Pain" logbook_page);
1208
-
Removed:
Alcotest.(check bool)
1209
-
Removed:
"does not store skipped factors" true
1210
-
Removed:
((not (contains ~substring:"Appetite" logbook_page))
1211
-
Removed:
&& not (contains ~substring:"Difficulty" logbook_page)) );
1212
-
Removed:
( "skipping the entire feedback flow records nothing",
1213
-
Removed:
`Quick,
1214
-
Removed:
fun () ->
1215
-
Removed:
let c = client () in
1216
-
Removed:
let _ = sign_in_new c in
1217
-
Removed:
let start = body (get c "/logbook?feedback=start") in
1218
-
Removed:
let token = Option.get (csrf_token start) in
1219
-
Removed:
let skipped = post c "/feedback/cancel" [ ("dream.csrf", token) ] in
1220
-
Removed:
Alcotest.(check int) "full-flow skip redirects" 303 (status skipped);
1221
-
Removed:
let logbook_page = body (get c "/logbook") in
1222
-
Removed:
Alcotest.(check bool)
1223
-
Removed:
"shows the skip notice" true
1224
-
Removed:
(contains ~substring:"Feedback skipped." logbook_page);
1225
-
Removed:
Alcotest.(check bool)
1226
-
Removed:
"does not add an empty report" false
1227
-
Removed:
(contains ~substring:"No signals" logbook_page) );
1228
-
Removed:
( "back returns to the previous factor and keeps answers",
1229
-
Removed:
`Quick,
1230
-
Removed:
fun () ->
1231
-
Removed:
let c = client () in
1232
-
Removed:
let _ = sign_in_new c in
1233
-
Removed:
let start = body (get c "/logbook?feedback=start") in
1234
-
Removed:
Alcotest.(check bool)
1235
-
Removed:
"the first factor offers no back control" false
1236
-
Removed:
(contains ~substring:"value=\"back\"" start);
1237
-
Removed:
let token = Option.get (csrf_token start) in
1238
-
Removed:
let _ =
1239
-
Removed:
post c "/feedback"
1240
-
Removed:
[
1241
-
Removed:
("dream.csrf", token);
1242
-
Removed:
("step", "0");
1243
-
Removed:
("action", "next");
1244
-
Removed:
("choice", "5");
1245
-
Removed:
]
1246
-
Removed:
in
1247
-
Removed:
let appetite = body (get c "/logbook") in
1248
-
Removed:
Alcotest.(check bool)
1249
-
Removed:
"the second factor offers a back control" true
1250
-
Removed:
(contains ~substring:">Appetite</h3>" appetite
1251
-
Removed:
&& contains ~substring:"value=\"back\"" appetite);
1252
-
Removed:
let token = Option.get (csrf_token appetite) in
1253
-
Removed:
let back =
1254
-
Removed:
post c "/feedback"
1255
-
Removed:
[ ("dream.csrf", token); ("step", "1"); ("action", "back") ]
1256
-
Removed:
in
1257
-
Removed:
Alcotest.(check int) "back redirects" 303 (status back);
1258
-
Removed:
let sleep_again = body (get c "/logbook") in
1259
-
Removed:
Alcotest.(check bool)
1260
-
Removed:
"back returns to the first factor" true
1261
-
Removed:
(contains ~substring:">Sleep</h3>" sleep_again
1262
-
Removed:
&& contains ~substring:"1 of 5 factors" sleep_again);
1263
-
Removed:
(* The earlier answer survives Back. Skipping forward from here
1264
-
Removed:
never touches it, so saving still stores the sleep signal. *)
1265
-
Removed:
let rec skip_to_notes page =
1266
-
Removed:
let token = Option.get (csrf_token page) in
1267
-
Removed:
let step_of =
1268
-
Removed:
if contains ~substring:">Sleep</h3>" page then 0
1269
-
Removed:
else if contains ~substring:">Appetite</h3>" page then 1
1270
-
Removed:
else if contains ~substring:">Readiness</h3>" page then 2
1271
-
Removed:
else if contains ~substring:">Motivation</h3>" page then 3
1272
-
Removed:
else if contains ~substring:">Perceived difficulty</h3>" page
1273
-
Removed:
then 4
1274
-
Removed:
else -1
1275
-
Removed:
in
1276
-
Removed:
if step_of < 0 then page
1277
-
Removed:
else begin
1278
-
Removed:
let _ =
1279
-
Removed:
post c "/feedback"
1280
-
Removed:
[
1281
-
Removed:
("dream.csrf", token);
1282
-
Removed:
("step", string_of_int step_of);
1283
-
Removed:
("action", "skip");
1284
-
Removed:
]
1285
-
Removed:
in
1286
-
Removed:
skip_to_notes (body (get c "/logbook"))
1287
-
Removed:
end
1288
-
Removed:
in
1289
-
Removed:
let notes = skip_to_notes sleep_again in
1290
-
Removed:
let token = Option.get (csrf_token notes) in
1291
-
Removed:
let _ =
1292
-
Removed:
post c "/feedback"
1293
-
Removed:
[ ("dream.csrf", token); ("step", "5"); ("action", "save") ]
1294
-
Removed:
in
1295
-
Removed:
let logbook_page = body (get c "/logbook") in
1296
-
Removed:
Alcotest.(check bool)
1297
-
Removed:
"the pre-back answer persists" true
1298
-
Removed:
(contains ~substring:"Sleep 5 (very good)" logbook_page) );
1299
-
Removed:
( "closing the feedback modal cancels the flow",
1300
-
Removed:
`Quick,
1301
-
Removed:
fun () ->
1302
-
Removed:
let c = client () in
1303
-
Removed:
let _ = sign_in_new c in
1304
-
Removed:
let start = body (get c "/logbook?feedback=start") in
1305
-
Removed:
(* Answer the first factor, then close: the flow is discarded and
1306
-
Removed:
reopening starts fresh at the first factor. *)
1307
-
Removed:
let token = Option.get (csrf_token start) in
1308
-
Removed:
let _ =
1309
-
Removed:
post c "/feedback"
1310
-
Removed:
[
1311
-
Removed:
("dream.csrf", token);
1312
-
Removed:
("step", "0");
1313
-
Removed:
("action", "next");
1314
-
Removed:
("choice", "5");
1315
-
Removed:
]
1316
-
Removed:
in
1317
-
Removed:
let appetite = body (get c "/logbook") in
1318
-
Removed:
let token = Option.get (csrf_token appetite) in
1319
-
Removed:
(* The close control posts to the cancel route. *)
1320
-
Removed:
let closed = post c "/feedback/cancel" [ ("dream.csrf", token) ] in
1321
-
Removed:
Alcotest.(check int) "close redirects" 303 (status closed);
1322
-
Removed:
let reopened = body (get c "/logbook?feedback=start") in
1323
-
Removed:
Alcotest.(check bool)
1324
-
Removed:
"reopening starts fresh at the first factor" true
1325
-
Removed:
(contains ~substring:">Sleep</h3>" reopened
1326
-
Removed:
&& contains ~substring:"1 of 5 factors" reopened) );
1327
-
Removed:
( "submitting feedback stores it and it appears on the logbook",
1328
-
Removed:
`Quick,
1329
-
Removed:
fun () ->
1330
-
Removed:
let c = client () in
1331
-
Removed:
let _ = sign_in_new c in
1332
-
Removed:
let start = body (get c "/logbook?feedback=start") in
1333
-
Removed:
let rec advance step page =
1334
-
Removed:
if step = 5 then page
1335
-
Removed:
else
1336
-
Removed:
let token = Option.get (csrf_token page) in
1337
-
Removed:
ignore
1338
-
Removed:
(post c "/feedback"
1339
-
Removed:
[
1340
-
Removed:
("dream.csrf", token);
1341
-
Removed:
("step", string_of_int step);
1342
-
Removed:
("action", "next");
1343
-
Removed:
("choice", if step = 0 then "1" else "skip");
1344
-
Removed:
]);
1345
-
Removed:
advance (step + 1) (body (get c "/logbook"))
1346
-
Removed:
in
1347
-
Removed:
let notes = advance 0 start in
1348
-
Removed:
let token = Option.get (csrf_token notes) in
1349
-
Removed:
let stored =
1350
-
Removed:
post c "/feedback"
1351
-
Removed:
[
1352
-
Removed:
("dream.csrf", token);
1353
-
Removed:
("step", "5");
1354
-
Removed:
("action", "save");
1355
-
Removed:
("pain", "true");
1356
-
Removed:
]
1357
-
Removed:
in
1358
-
Removed:
Alcotest.(check int) "feedback stored" 303 (status stored);
1359
-
Removed:
let logbook_page = body (get c "/logbook") in
1360
-
Removed:
Alcotest.(check bool)
1361
-
Removed:
"shows the reported sleep signal" true
1362
-
Removed:
(contains ~substring:"Sleep 1 (very poor)" logbook_page);
1363
-
Removed:
Alcotest.(check bool)
1364
-
Removed:
"shows the reported pain signal" true
1365
-
Removed:
(contains ~substring:"Pain" logbook_page) );
1366
-
Removed:
( "the logbook graphs leveled feedback once two reports exist",
1367
-
Removed:
`Quick,
1368
-
Removed:
fun () ->
1369
-
Removed:
let c = client () in
1370
-
Removed:
let _ = sign_in_new c in
1371
-
Removed:
(* Record one full feedback report: answer sleep, skip the rest. *)
1372
-
Removed:
let record_sleep score =
1373
-
Removed:
let start = body (get c "/logbook?feedback=start") in
1374
-
Removed:
let token = Option.get (csrf_token start) in
1375
-
Removed:
let _ =
1376
-
Removed:
post c "/feedback"
1377
-
Removed:
[
1378
-
Removed:
("dream.csrf", token);
1379
-
Removed:
("step", "0");
1380
-
Removed:
("action", "next");
1381
-
Removed:
("choice", score);
1382
-
Removed:
]
1383
-
Removed:
in
1384
-
Removed:
let rec skip step =
1385
-
Removed:
if step < 5 then begin
1386
-
Removed:
let token =
1387
-
Removed:
Option.get (csrf_token (body (get c "/logbook")))
1388
-
Removed:
in
1389
-
Removed:
let _ =
1390
-
Removed:
post c "/feedback"
1391
-
Removed:
[
1392
-
Removed:
("dream.csrf", token);
1393
-
Removed:
("step", string_of_int step);
1394
-
Removed:
("action", "skip");
1395
-
Removed:
]
1396
-
Removed:
in
1397
-
Removed:
skip (step + 1)
1398
-
Removed:
end
1399
-
Removed:
in
1400
-
Removed:
skip 1;
1401
-
Removed:
let token = Option.get (csrf_token (body (get c "/logbook"))) in
1402
-
Removed:
ignore
1403
-
Removed:
(post c "/feedback"
1404
-
Removed:
[ ("dream.csrf", token); ("step", "5"); ("action", "save") ])
1405
-
Removed:
in
1406
-
Removed:
(* One report: no graph yet, a single point is not a trend. *)
1407
-
Removed:
record_sleep "2";
1408
-
Removed:
let one = body (get c "/logbook") in
1409
-
Removed:
Alcotest.(check bool)
1410
-
Removed:
"no graph for a single report" false
1411
-
Removed:
(contains ~substring:"feedback-graph" one);
1412
-
Removed:
(* A second report: the graph appears with an SVG polyline. *)
1413
-
Removed:
record_sleep "4";
1414
-
Removed:
let two = body (get c "/logbook") in
1415
-
Removed:
Alcotest.(check bool)
1416
-
Removed:
"graphs feedback over time" true
1417
-
Removed:
(contains ~substring:"feedback-graph" two
1418
-
Removed:
&& contains ~substring:"<svg" two
1419
-
Removed:
&& contains ~substring:"<polyline" two
1420
-
Removed:
&& contains ~substring:"Feedback over time" two) );
1421
-
Removed:
( "finishing a workout suggests feedback on the logbook",
1422
-
Removed:
`Quick,
1423
-
Removed:
fun () ->
1424
-
Removed:
let c = client () in
1425
-
Removed:
let _ = sign_in_new c in
1426
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1427
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1428
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1429
-
Removed:
let _ =
1430
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1431
-
Removed:
in
1432
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1433
-
Removed:
let finished = post c "/workout/finish" [ ("dream.csrf", token) ] in
1434
-
Removed:
Alcotest.(check int) "finish redirects" 303 (status finished);
1435
-
Removed:
Alcotest.(check bool)
1436
-
Removed:
"redirects to the logbook with a feedback prompt" true
1437
-
Removed:
(List.mem "/logbook?prompt=feedback"
1438
-
Removed:
(Dream.headers finished "Location"));
1439
-
Removed:
let prompted = body (get c "/logbook?prompt=feedback") in
1440
-
Removed:
Alcotest.(check bool)
1441
-
Removed:
"shows the feedback suggestion" true
1442
-
Removed:
(contains ~substring:"add feedback while it is fresh" prompted);
1443
-
Removed:
(* The gated form opens on a suggestion, so the prompt lands on a
1444
-
Removed:
visible form rather than a collapsed disclosure. *)
1445
-
Removed:
Alcotest.(check bool)
1446
-
Removed:
"opens the feedback modal on a suggestion" true
1447
-
Removed:
(contains ~substring:"data-hito-feedback-modal" prompted
1448
-
Removed:
&& contains ~substring:" open" prompted) );
1449
-
Removed:
( "beginning under override is gated behind a confirmation modal",
1450
-
Removed:
`Quick,
1451
-
Removed:
fun () ->
1452
-
Removed:
let c = client () in
1453
-
Removed:
let _ = sign_in_new c in
1454
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1455
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1456
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1457
-
Removed:
let _ =
1458
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1459
-
Removed:
in
1460
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1461
-
Removed:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
1462
-
Removed:
(* Recovery has not elapsed, so Home shows the override gate. The
1463
-
Removed:
begin form carries the shared confirm attributes and a matching
1464
-
Removed:
dialog, so the deviation is possible but never silent. *)
1465
-
Removed:
let home = body (get c "/") in
1466
-
Removed:
Alcotest.(check bool)
1467
-
Removed:
"still requires recovery" true
1468
-
Removed:
(contains ~substring:"requires recovery" home);
1469
-
Removed:
Alcotest.(check bool)
1470
-
Removed:
"gates the override submit behind the confirm machinery" true
1471
-
Removed:
(contains ~substring:"data-hito-confirm-cancel" home);
1472
-
Removed:
Alcotest.(check bool)
1473
-
Removed:
"posts the override through a confirm form" true
1474
-
Removed:
(contains ~substring:"data-hito-confirm-form" home);
1475
-
Removed:
Alcotest.(check bool)
1476
-
Removed:
"carries the confirmation modal" true
1477
-
Removed:
(contains ~substring:"data-hito-confirm-modal" home);
1478
-
Removed:
Alcotest.(check bool)
1479
-
Removed:
"still submits the override without script" true
1480
-
Removed:
(contains ~substring:"value=\"true\"" home) );
1481
-
Removed:
( "an overridden workout shows the early notice as a toast",
1482
-
Removed:
`Quick,
1483
-
Removed:
fun () ->
1484
-
Removed:
let c = client () in
1485
-
Removed:
let _ = sign_in_new c in
1486
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1487
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1488
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1489
-
Removed:
let _ =
1490
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1491
-
Removed:
in
1492
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1493
-
Removed:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
1494
-
Removed:
(* Recovery has not elapsed, so the next workout must be started
1495
-
Removed:
under override. *)
1496
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1497
-
Removed:
let _ =
1498
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "true") ]
1499
-
Removed:
in
1500
-
Removed:
let workout = body (get c "/workout") in
1501
-
Removed:
Alcotest.(check bool)
1502
-
Removed:
"renders the notice as a toast" true
1503
-
Removed:
(contains ~substring:"data-hito-toast" workout);
1504
-
Removed:
Alcotest.(check bool)
1505
-
Removed:
"announces the toast politely" true
1506
-
Removed:
(contains ~substring:"role=\"status\"" workout);
1507
-
Removed:
Alcotest.(check bool)
1508
-
Removed:
"keeps the early-workout wording" true
1509
-
Removed:
(contains ~substring:"Begun before recovery finished." workout);
1510
-
Removed:
Alcotest.(check bool)
1511
-
Removed:
"no longer renders the notice as a plain warn block" false
1512
-
Removed:
(contains ~substring:"<div class=\"warn\">Begun before recovery"
1513
-
Removed:
workout) );
1514
-
Removed:
] );
1515
-
Removed:
( "web.app_feedback",
1516
-
Removed:
[
1517
-
Removed:
( "the header offers an app feedback button beside the profile control",
1518
-
Removed:
`Quick,
1519
-
Removed:
fun () ->
1520
-
Removed:
let c = client () in
1521
-
Removed:
let _ = sign_in_new c in
1522
-
Removed:
let page = body (get c "/") in
1523
-
Removed:
Alcotest.(check bool)
1524
-
Removed:
"renders the app feedback button" true
1525
-
Removed:
(contains ~substring:"class=\"app-feedback-button\"" page);
1526
-
Removed:
Alcotest.(check bool)
1527
-
Removed:
"links to the app feedback screen" true
1528
-
Removed:
(contains ~substring:"href=\"/app-feedback\"" page);
1529
-
Removed:
Alcotest.(check bool)
1530
-
Removed:
"labels the button Feedback" true
1531
-
Removed:
(contains ~substring:">Feedback</a>" page) );
1532
-
Removed:
( "the app feedback screen has write and submitted tabs",
1533
-
Removed:
`Quick,
1534
-
Removed:
fun () ->
1535
-
Removed:
let c = client () in
1536
-
Removed:
let _ = sign_in_new c in
1537
-
Removed:
let write_page = body (get c "/app-feedback") in
1538
-
Removed:
Alcotest.(check bool)
1539
-
Removed:
"starts with an app feedback heading" true
1540
-
Removed:
(contains ~substring:"<h1>App feedback</h1>" write_page);
1541
-
Removed:
Alcotest.(check bool)
1542
-
Removed:
"offers the write tab" true
1543
-
Removed:
(contains ~substring:"Write feedback" write_page);
1544
-
Removed:
Alcotest.(check bool)
1545
-
Removed:
"offers the submitted tab" true
1546
-
Removed:
(contains ~substring:"Submitted feedback" write_page);
1547
-
Removed:
Alcotest.(check bool)
1548
-
Removed:
"renders a freeform textarea" true
1549
-
Removed:
(contains ~substring:"id=\"app-feedback-message\"" write_page
1550
-
Removed:
&& contains ~substring:"<textarea" write_page);
1551
-
Removed:
Alcotest.(check bool)
1552
-
Removed:
"renders one primary submit action" true
1553
-
Removed:
(contains ~substring:"value=\"Submit feedback\"" write_page);
1554
-
Removed:
let submitted_page = body (get c "/app-feedback?tab=submitted") in
1555
-
Removed:
Alcotest.(check bool)
1556
-
Removed:
"submitted tab is selected" true
1557
-
Removed:
(contains ~substring:"href=\"/app-feedback?tab=submitted\""
1558
-
Removed:
submitted_page
1559
-
Removed:
&& contains ~substring:"You have not submitted" submitted_page) );
1560
-
Removed:
( "blank app feedback is refused and a submitted message is listed",
1561
-
Removed:
`Quick,
1562
-
Removed:
fun () ->
1563
-
Removed:
let c = client () in
1564
-
Removed:
let _ = sign_in_new c in
1565
-
Removed:
let page = body (get c "/app-feedback") in
1566
-
Removed:
let token = Option.get (csrf_token page) in
1567
-
Removed:
let blank =
1568
-
Removed:
post c "/app-feedback/submit"
1569
-
Removed:
[ ("dream.csrf", token); ("message", " ") ]
1570
-
Removed:
in
1571
-
Removed:
Alcotest.(check int) "blank message redirects" 303 (status blank);
1572
-
Removed:
Alcotest.(check bool)
1573
-
Removed:
"redirects to the write tab" true
1574
-
Removed:
(List.mem "/app-feedback?tab=write"
1575
-
Removed:
(Dream.headers blank "Location"));
1576
-
Removed:
let blank_page = body (get c "/app-feedback?tab=write") in
1577
-
Removed:
Alcotest.(check bool)
1578
-
Removed:
"preserves the validation error as a toast" true
1579
-
Removed:
(contains ~substring:"Enter feedback before submitting" blank_page);
1580
-
Removed:
let token =
1581
-
Removed:
Option.get (csrf_token (body (get c "/app-feedback")))
1582
-
Removed:
in
1583
-
Removed:
let submitted =
1584
-
Removed:
post c "/app-feedback/submit"
1585
-
Removed:
[
1586
-
Removed:
("dream.csrf", token);
1587
-
Removed:
("message", "The app is clear.\nThank you!");
1588
-
Removed:
]
1589
-
Removed:
in
1590
-
Removed:
Alcotest.(check int) "message is accepted" 303 (status submitted);
1591
-
Removed:
Alcotest.(check bool)
1592
-
Removed:
"redirects to submitted feedback" true
1593
-
Removed:
(List.mem "/app-feedback?tab=submitted"
1594
-
Removed:
(Dream.headers submitted "Location"));
1595
-
Removed:
let list_page = body (get c "/app-feedback?tab=submitted") in
1596
-
Removed:
Alcotest.(check bool)
1597
-
Removed:
"shows the success toast" true
1598
-
Removed:
(contains ~substring:"Feedback submitted." list_page);
1599
-
Removed:
Alcotest.(check bool)
1600
-
Removed:
"lists the submitted message" true
1601
-
Removed:
(contains ~substring:"The app is clear.\nThank you!" list_page);
1602
-
Removed:
Alcotest.(check bool)
1603
-
Removed:
"shows its timestamp" true
1604
-
Removed:
(contains ~substring:"app-feedback-time" list_page) );
1605
-
Removed:
( "feedback lists authors and accepts another user's vote",
1606
-
Removed:
`Quick,
1607
-
Removed:
fun () ->
1608
-
Removed:
let alice = client () in
1609
-
Removed:
let _ = sign_in_new alice in
1610
-
Removed:
let bob = { alice with jar = [] } in
1611
-
Removed:
let _ = register bob ~username:"bobby" ~password:"heavyduty1" in
1612
-
Removed:
let submit client message =
1613
-
Removed:
let page = body (get client "/app-feedback") in
1614
-
Removed:
let token = Option.get (csrf_token page) in
1615
-
Removed:
post client "/app-feedback/submit"
1616
-
Removed:
[ ("dream.csrf", token); ("message", message) ]
1617
-
Removed:
in
1618
-
Removed:
Alcotest.(check int)
1619
-
Removed:
"alice feedback accepted" 303
1620
-
Removed:
(status (submit alice "Alice report"));
1621
-
Removed:
Alcotest.(check int)
1622
-
Removed:
"bob feedback accepted" 303
1623
-
Removed:
(status (submit bob "Bob report"));
1624
-
Removed:
let list_page = body (get alice "/app-feedback?tab=submitted") in
1625
-
Removed:
Alcotest.(check bool)
1626
-
Removed:
"shows the author" true
1627
-
Removed:
(contains ~substring:"bobby — 1 contributions" list_page);
1628
-
Removed:
Alcotest.(check bool)
1629
-
Removed:
"shows the ranked vote control" true
1630
-
Removed:
(contains ~substring:"action=\"/app-feedback/t2:1/upvote\""
1631
-
Removed:
list_page);
1632
-
Removed:
let token = Option.get (csrf_token list_page) in
1633
-
Removed:
let voted =
1634
-
Removed:
post alice "/app-feedback/t2:1/upvote" [ ("dream.csrf", token) ]
1635
-
Removed:
in
1636
-
Removed:
Alcotest.(check int) "upvote redirects" 303 (status voted);
1637
-
Removed:
let ranked = body (get alice "/app-feedback?tab=submitted") in
1638
-
Removed:
Alcotest.(check bool)
1639
-
Removed:
"shows the vote count" true
1640
-
Removed:
(contains ~substring:"Upvotes: 1" ranked);
1641
-
Removed:
Alcotest.(check bool)
1642
-
Removed:
"marks the viewer's vote" true
1643
-
Removed:
(contains ~substring:"value=\"Upvoted\"" ranked);
1644
-
Removed:
let bob_page = body (get bob "/app-feedback?tab=submitted") in
1645
-
Removed:
Alcotest.(check bool)
1646
-
Removed:
"shows owner actions" true
1647
-
Removed:
(contains ~substring:"action=\"/app-feedback/t2:1/edit\"" bob_page
1648
-
Removed:
&& contains ~substring:"action=\"/app-feedback/t2:1/remove\""
1649
-
Removed:
bob_page);
1650
-
Removed:
Alcotest.(check bool)
1651
-
Removed:
"enhances owner actions through the app form path" true
1652
-
Removed:
(contains ~substring:"app-feedback-edit-form" bob_page
1653
-
Removed:
&& contains ~substring:"app-feedback-remove-form" bob_page
1654
-
Removed:
&& contains ~substring:"data-hito-app-form" bob_page);
1655
-
Removed:
let edit_token = Option.get (csrf_token bob_page) in
1656
-
Removed:
let blank =
1657
-
Removed:
post bob "/app-feedback/t2:1/edit"
1658
-
Removed:
[ ("dream.csrf", edit_token); ("message", " ") ]
1659
-
Removed:
in
1660
-
Removed:
Alcotest.(check int) "blank edit redirects" 303 (status blank);
1661
-
Removed:
let blank_page = body (get bob "/app-feedback?tab=submitted") in
1662
-
Removed:
Alcotest.(check bool)
1663
-
Removed:
"blank edit is refused" true
1664
-
Removed:
(contains ~substring:"Enter feedback before submitting."
1665
-
Removed:
blank_page
1666
-
Removed:
&& contains ~substring:"Bob report" blank_page);
1667
-
Removed:
let edit_token = Option.get (csrf_token blank_page) in
1668
-
Removed:
let edited =
1669
-
Removed:
post bob "/app-feedback/t2:1/edit"
1670
-
Removed:
[ ("dream.csrf", edit_token); ("message", "Edited report") ]
1671
-
Removed:
in
1672
-
Removed:
Alcotest.(check int) "edit redirects" 303 (status edited);
1673
-
Removed:
let edited_page = body (get bob "/app-feedback?tab=submitted") in
1674
-
Removed:
Alcotest.(check bool)
1675
-
Removed:
"shows edited feedback" true
1676
-
Removed:
(contains ~substring:"Edited report" edited_page);
1677
-
Removed:
let remove_token = Option.get (csrf_token edited_page) in
1678
-
Removed:
let removed =
1679
-
Removed:
post bob "/app-feedback/t2:1/remove"
1680
-
Removed:
[ ("dream.csrf", remove_token) ]
1681
-
Removed:
in
1682
-
Removed:
Alcotest.(check int) "remove redirects" 303 (status removed);
1683
-
Removed:
let after_remove = body (get bob "/app-feedback?tab=submitted") in
1684
-
Removed:
Alcotest.(check bool)
1685
-
Removed:
"removes the owner's feedback" false
1686
-
Removed:
(contains ~substring:"Edited report" after_remove) );
1687
-
Removed:
( "another trainee cannot edit or remove app feedback",
1688
-
Removed:
`Quick,
1689
-
Removed:
fun () ->
1690
-
Removed:
let alice = client () in
1691
-
Removed:
let _ = register alice ~username:"alice" ~password:"heavyduty1" in
1692
-
Removed:
let bob = { alice with jar = [] } in
1693
-
Removed:
let _ = register bob ~username:"bobby" ~password:"heavyduty1" in
1694
-
Removed:
let bob_page = body (get bob "/app-feedback") in
1695
-
Removed:
let token = Option.get (csrf_token bob_page) in
1696
-
Removed:
let _ =
1697
-
Removed:
post bob "/app-feedback/submit"
1698
-
Removed:
[ ("dream.csrf", token); ("message", "Bob owns this") ]
1699
-
Removed:
in
1700
-
Removed:
let alice_page = body (get alice "/app-feedback?tab=submitted") in
1701
-
Removed:
let token = Option.get (csrf_token alice_page) in
1702
-
Removed:
let edited =
1703
-
Removed:
post alice "/app-feedback/t2:1/edit"
1704
-
Removed:
[ ("dream.csrf", token); ("message", "Taken over") ]
1705
-
Removed:
in
1706
-
Removed:
Alcotest.(check int) "cross-user edit redirects" 303 (status edited);
1707
-
Removed:
let after_edit = body (get alice "/app-feedback?tab=submitted") in
1708
-
Removed:
Alcotest.(check bool)
1709
-
Removed:
"cross-user edit changes nothing" true
1710
-
Removed:
(contains ~substring:"Bob owns this" after_edit
1711
-
Removed:
&& not (contains ~substring:"Taken over" after_edit));
1712
-
Removed:
let token = Option.get (csrf_token after_edit) in
1713
-
Removed:
let removed =
1714
-
Removed:
post alice "/app-feedback/t2:1/remove" [ ("dream.csrf", token) ]
1715
-
Removed:
in
1716
-
Removed:
Alcotest.(check int)
1717
-
Removed:
"cross-user remove redirects" 303 (status removed);
1718
-
Removed:
let after_remove = body (get alice "/app-feedback?tab=submitted") in
1719
-
Removed:
Alcotest.(check bool)
1720
-
Removed:
"cross-user remove changes nothing" true
1721
-
Removed:
(contains ~substring:"Bob owns this" after_remove) );
1722
-
Removed:
] );
1723
-
Removed:
( "web.profile",
1724
-
Removed:
[
1725
-
Removed:
( "the profile page offers username and password forms",
1726
-
Removed:
`Quick,
1727
-
Removed:
fun () ->
1728
-
Removed:
let c = client () in
1729
-
Removed:
let _ = sign_in_new c in
1730
-
Removed:
let page = body (get c "/profile") in
1731
-
Removed:
Alcotest.(check bool)
1732
-
Removed:
"starts with a page heading" true
1733
-
Removed:
(contains ~substring:"<h1>Profile</h1>" page);
1734
-
Removed:
Alcotest.(check bool)
1735
-
Removed:
"posts a username change to its route" true
1736
-
Removed:
(contains ~substring:"action=\"/profile/username\"" page);
1737
-
Removed:
Alcotest.(check bool)
1738
-
Removed:
"posts a password change to its route" true
1739
-
Removed:
(contains ~substring:"action=\"/profile/password\"" page);
1740
-
Removed:
Alcotest.(check bool)
1741
-
Removed:
"prefills the current username" true
1742
-
Removed:
(contains ~substring:"value=\"lifter\"" page);
1743
-
Removed:
Alcotest.(check bool)
1744
-
Removed:
"asks for the current password" true
1745
-
Removed:
(contains ~substring:"name=\"current\"" page);
1746
-
Removed:
Alcotest.(check bool)
1747
-
Removed:
"shows the username value, not a bare field" true
1748
-
Removed:
(contains ~substring:"profile-value-text" page);
1749
-
Removed:
Alcotest.(check bool)
1750
-
Removed:
"offers a change-username button that opens a dialog" true
1751
-
Removed:
(contains ~substring:"data-hito-dialog-open=\"username-dialog\""
1752
-
Removed:
page);
1753
-
Removed:
Alcotest.(check bool)
1754
-
Removed:
"offers a change-password button that opens a dialog" true
1755
-
Removed:
(contains ~substring:"data-hito-dialog-open=\"password-dialog\""
1756
-
Removed:
page);
1757
-
Removed:
Alcotest.(check bool)
1758
-
Removed:
"wraps each edit form in a dialog" true
1759
-
Removed:
(contains ~substring:"<dialog id=\"username-dialog\"" page
1760
-
Removed:
&& contains ~substring:"<dialog id=\"password-dialog\"" page) );
1761
-
Removed:
( "changing the username updates the profile and the navigation",
1762
-
Removed:
`Quick,
1763
-
Removed:
fun () ->
1764
-
Removed:
let c = client () in
1765
-
Removed:
let _ = sign_in_new c in
1766
-
Removed:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1767
-
Removed:
let changed =
1768
-
Removed:
post c "/profile/username"
1769
-
Removed:
[ ("dream.csrf", token); ("username", "athlete") ]
1770
-
Removed:
in
1771
-
Removed:
Alcotest.(check int)
1772
-
Removed:
"redirects after the change" 303 (status changed);
1773
-
Removed:
Alcotest.(check bool)
1774
-
Removed:
"redirects back to the profile with a notice" true
1775
-
Removed:
(List.mem "/profile?changed=username"
1776
-
Removed:
(Dream.headers changed "Location"));
1777
-
Removed:
let page = body (get c "/profile?changed=username") in
1778
-
Removed:
Alcotest.(check bool)
1779
-
Removed:
"confirms the change" true
1780
-
Removed:
(contains ~substring:"Username changed" page);
1781
-
Removed:
Alcotest.(check bool)
1782
-
Removed:
"shows the new name in the profile control" true
1783
-
Removed:
(contains ~substring:">athlete</summary>" page) );
1784
-
Removed:
( "a taken username is refused with an inline error",
1785
-
Removed:
`Quick,
1786
-
Removed:
fun () ->
1787
-
Removed:
let c = client () in
1788
-
Removed:
let _ = sign_in_new c in
1789
-
Removed:
(* Register a second account in the same store, via a client that
1790
-
Removed:
shares the app but keeps its own cookie jar. *)
1791
-
Removed:
let other = { c with jar = [] } in
1792
-
Removed:
let _ = register other ~username:"taken" ~password:"heavyduty1" in
1793
-
Removed:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1794
-
Removed:
let refused =
1795
-
Removed:
post c "/profile/username"
1796
-
Removed:
[ ("dream.csrf", token); ("username", "taken") ]
1797
-
Removed:
in
1798
-
Removed:
Alcotest.(check int)
1799
-
Removed:
"renders an invalid response" 400 (status refused);
1800
-
Removed:
Alcotest.(check bool)
1801
-
Removed:
"explains the name is taken" true
1802
-
Removed:
(contains ~substring:"already registered" (body refused));
1803
-
Removed:
Alcotest.(check bool)
1804
-
Removed:
"opens the username dialog to show the error" true
1805
-
Removed:
(contains ~substring:"class=\"profile-dialog\" open"
1806
-
Removed:
(body refused)) );
1807
-
Removed:
( "changing the password requires the current one",
1808
-
Removed:
`Quick,
1809
-
Removed:
fun () ->
1810
-
Removed:
let c = client () in
1811
-
Removed:
let _ = sign_in_new c in
1812
-
Removed:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1813
-
Removed:
let refused =
1814
-
Removed:
post c "/profile/password"
1815
-
Removed:
[
1816
-
Removed:
("dream.csrf", token);
1817
-
Removed:
("current", "wrongpass");
1818
-
Removed:
("next", "newsecret1");
1819
-
Removed:
]
1820
-
Removed:
in
1821
-
Removed:
Alcotest.(check int)
1822
-
Removed:
"renders an invalid response" 400 (status refused);
1823
-
Removed:
Alcotest.(check bool)
1824
-
Removed:
"explains the current password is wrong" true
1825
-
Removed:
(contains ~substring:"current password is not correct"
1826
-
Removed:
(body refused));
1827
-
Removed:
(* Now change it with the correct current password. *)
1828
-
Removed:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1829
-
Removed:
let changed =
1830
-
Removed:
post c "/profile/password"
1831
-
Removed:
[
1832
-
Removed:
("dream.csrf", token);
1833
-
Removed:
("current", "heavyduty1");
1834
-
Removed:
("next", "newsecret1");
1835
-
Removed:
]
1836
-
Removed:
in
1837
-
Removed:
Alcotest.(check int)
1838
-
Removed:
"redirects after the change" 303 (status changed);
1839
-
Removed:
Alcotest.(check bool)
1840
-
Removed:
"redirects back to the profile with a notice" true
1841
-
Removed:
(List.mem "/profile?changed=password"
1842
-
Removed:
(Dream.headers changed "Location")) );
1843
-
Removed:
] );
1844
-
Removed:
( "web.errors",
1845
-
Removed:
[
1846
-
Removed:
( "an unknown path renders a branded 404 with navigation",
1847
-
Removed:
`Quick,
1848
-
Removed:
fun () ->
1849
-
Removed:
let c = client () in
1850
-
Removed:
let response = get c "/no-such-page" in
1851
-
Removed:
Alcotest.(check int) "responds 404" 404 (status response);
1852
-
Removed:
let page = body response in
1853
-
Removed:
Alcotest.(check bool)
1854
-
Removed:
"names the failure without leaking server detail" true
1855
-
Removed:
(contains ~substring:"Page not found" page);
1856
-
Removed:
Alcotest.(check bool)
1857
-
Removed:
"keeps the desktop top navigation" true
1858
-
Removed:
(contains ~substring:"primary-nav" page);
1859
-
Removed:
Alcotest.(check bool)
1860
-
Removed:
"keeps the mobile bottom navigation" true
1861
-
Removed:
(contains ~substring:"bottom-nav" page);
1862
-
Removed:
Alcotest.(check bool)
1863
-
Removed:
"offers a route back Home" true
1864
-
Removed:
(contains ~substring:"href=\"/\"" page) );
1865
-
Removed:
( "the 404 page never depends on a session",
1866
-
Removed:
`Quick,
1867
-
Removed:
fun () ->
1868
-
Removed:
(* No sign-in: an unauthenticated visitor still gets the branded
1869
-
Removed:
page and its navigation, not an empty body. *)
1870
-
Removed:
let c = client () in
1871
-
Removed:
let page = body (get c "/deep/unknown/path") in
1872
-
Removed:
Alcotest.(check bool)
1873
-
Removed:
"renders the branded page" true
1874
-
Removed:
(contains ~substring:"Page not found" page);
1875
-
Removed:
Alcotest.(check bool)
1876
-
Removed:
"shows navigation actions" true
1877
-
Removed:
(contains ~substring:"Logbook" page) );
1878
-
Removed:
] );
1879
-
Removed:
( "web.sign_in",
1880
-
Removed:
[
1881
-
Removed:
( "a returning trainee signs in with their credentials",
1882
-
Removed:
`Quick,
1883
-
Removed:
fun () ->
1884
-
Removed:
(* Register, then sign out, then sign back in through the credential
1885
-
Removed:
form. This exercises [POST /login], which registration's
1886
-
Removed:
auto-sign-in never touches. *)
1887
-
Removed:
let c = client () in
1888
-
Removed:
let _ = sign_in_new c in
1889
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1890
-
Removed:
let _ = post c "/logout" [ ("dream.csrf", token) ] in
1891
-
Removed:
Alcotest.(check int)
1892
-
Removed:
"signed out, overview redirects" 303
1893
-
Removed:
(status (get c "/"));
1894
-
Removed:
let signed_in =
1895
-
Removed:
sign_in c ~username:"lifter" ~password:"heavyduty1"
1896
-
Removed:
in
1897
-
Removed:
Alcotest.(check int) "sign-in redirects" 303 (status signed_in);
1898
-
Removed:
Alcotest.(check bool)
1899
-
Removed:
"sign-in lands on Home" true
1900
-
Removed:
(List.mem "/" (Dream.headers signed_in "Location"));
1901
-
Removed:
Alcotest.(check int)
1902
-
Removed:
"authenticated overview" 200
1903
-
Removed:
(status (get c "/")) );
1904
-
Removed:
( "a wrong password is refused with an unauthorized message",
1905
-
Removed:
`Quick,
1906
-
Removed:
fun () ->
1907
-
Removed:
let c = client () in
1908
-
Removed:
let _ = sign_in_new c in
1909
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1910
-
Removed:
let _ = post c "/logout" [ ("dream.csrf", token) ] in
1911
-
Removed:
let refused =
1912
-
Removed:
sign_in c ~username:"lifter" ~password:"not-the-password"
1913
-
Removed:
in
1914
-
Removed:
Alcotest.(check int) "rejected" 401 (status refused);
1915
-
Removed:
Alcotest.(check bool)
1916
-
Removed:
"explains the mismatch without leaking which field failed" true
1917
-
Removed:
(contains ~substring:"do not match" (body refused));
1918
-
Removed:
(* The rejected attempt establishes no session. *)
1919
-
Removed:
Alcotest.(check int)
1920
-
Removed:
"still unauthenticated" 303
1921
-
Removed:
(status (get c "/")) );
1922
-
Removed:
( "an unknown username is refused like a wrong password",
1923
-
Removed:
`Quick,
1924
-
Removed:
fun () ->
1925
-
Removed:
let c = client () in
1926
-
Removed:
let refused = sign_in c ~username:"nobody" ~password:"heavyduty1" in
1927
-
Removed:
Alcotest.(check int) "rejected" 401 (status refused);
1928
-
Removed:
Alcotest.(check bool)
1929
-
Removed:
"gives the same generic message" true
1930
-
Removed:
(contains ~substring:"do not match" (body refused)) );
1931
-
Removed:
( "an already-authenticated visit to the sign-in page redirects Home",
1932
-
Removed:
`Quick,
1933
-
Removed:
fun () ->
1934
-
Removed:
let c = client () in
1935
-
Removed:
let _ = sign_in_new c in
1936
-
Removed:
let response = get c "/login" in
1937
-
Removed:
Alcotest.(check int) "redirects" 303 (status response);
1938
-
Removed:
Alcotest.(check bool)
1939
-
Removed:
"sends an authenticated visitor Home" true
1940
-
Removed:
(List.mem "/" (Dream.headers response "Location")) );
1941
-
Removed:
( "an already-authenticated visit to the register page redirects Home",
1942
-
Removed:
`Quick,
1943
-
Removed:
fun () ->
1944
-
Removed:
let c = client () in
1945
-
Removed:
let _ = sign_in_new c in
1946
-
Removed:
let response = get c "/register" in
1947
-
Removed:
Alcotest.(check int) "redirects" 303 (status response);
1948
-
Removed:
Alcotest.(check bool)
1949
-
Removed:
"sends an authenticated visitor Home" true
1950
-
Removed:
(List.mem "/" (Dream.headers response "Location")) );
1951
-
Removed:
] );
1952
-
Removed:
( "web.routine_selection",
1953
-
Removed:
[
1954
-
Removed:
( "selecting an unknown routine is not found",
1955
-
Removed:
`Quick,
1956
-
Removed:
fun () ->
1957
-
Removed:
let c = client () in
1958
-
Removed:
let _ = sign_in_new c in
1959
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1960
-
Removed:
let response =
1961
-
Removed:
post c "/routines/no-such-routine/select"
1962
-
Removed:
[ ("dream.csrf", token) ]
1963
-
Removed:
in
1964
-
Removed:
Alcotest.(check int) "responds 404" 404 (status response);
1965
-
Removed:
Alcotest.(check bool)
1966
-
Removed:
"names the missing routine" true
1967
-
Removed:
(contains ~substring:"not in the catalogue" (body response)) );
1968
-
Removed:
] );
1969
-
Removed:
( "web.saved_record_add",
1970
-
Removed:
[
1971
-
Removed:
( "an outstanding slot of a saved workout can be recorded, not only \
1972
-
Removed:
corrected",
1973
-
Removed:
`Quick,
1974
-
Removed:
fun () ->
1975
-
Removed:
(* Finish a workout with a single slot recorded, so the saved record
1976
-
Removed:
still has outstanding slots. Then record one of those through the
1977
-
Removed:
plain add path [POST /logbook/:id/slots/:d], which the edit tests
1978
-
Removed:
never exercise. *)
1979
-
Removed:
let c = client () in
1980
-
Removed:
let _ = sign_in_new c in
1981
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1982
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1983
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
1984
-
Removed:
let _ =
1985
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1986
-
Removed:
in
1987
-
Removed:
(* Record only slot 1, then finish. Slots 0, 2, 3 stay outstanding. *)
1988
-
Removed:
let token =
1989
-
Removed:
Option.get (csrf_token (body (get c "/workout?slot=1")))
1990
-
Removed:
in
1991
-
Removed:
let _ =
1992
-
Removed:
post c "/workout/slots/1"
1993
-
Removed:
[
1994
-
Removed:
("dream.csrf", token);
1995
-
Removed:
("load", "12");
1996
-
Removed:
("reps", "8");
1997
-
Removed:
("extension", "");
1998
-
Removed:
]
1999
-
Removed:
in
2000
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
2001
-
Removed:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
2002
-
Removed:
(* The saved workout is w1, still incomplete. Its slot 2 awaits a
2003
-
Removed:
record; add it through the non-edit path. *)
2004
-
Removed:
let record_page = body (get c "/logbook/w1?slot=2") in
2005
-
Removed:
Alcotest.(check bool)
2006
-
Removed:
"the add form posts to the plain slot route" true
2007
-
Removed:
(contains ~substring:"/logbook/w1/slots/2\"" record_page);
2008
-
Removed:
let token = Option.get (csrf_token record_page) in
2009
-
Removed:
let added =
2010
-
Removed:
post c "/logbook/w1/slots/2"
2011
-
Removed:
[
2012
-
Removed:
("dream.csrf", token);
2013
-
Removed:
("load", "10");
2014
-
Removed:
("reps", "9");
2015
-
Removed:
("extension", "");
2016
-
Removed:
]
2017
-
Removed:
in
2018
-
Removed:
Alcotest.(check int)
2019
-
Removed:
"recorded a saved-workout slot" 303 (status added);
2020
-
Removed:
let after = body (get c "/logbook/w1?slot=2") in
2021
-
Removed:
Alcotest.(check bool)
2022
-
Removed:
"now counts two filled slots" true
2023
-
Removed:
(contains ~substring:"2 of 4 recorded" after);
2024
-
Removed:
Alcotest.(check bool)
2025
-
Removed:
"shows the newly recorded load" true
2026
-
Removed:
(contains ~substring:"10" after) );
2027
-
Removed:
( "recording an already-filled slot through the add path is refused",
2028
-
Removed:
`Quick,
2029
-
Removed:
fun () ->
2030
-
Removed:
(* The add path lists only outstanding slots. A filled slot is not
2031
-
Removed:
awaiting a record, so posting to it is a 404, not a silent second
2032
-
Removed:
entry that would add volume. *)
2033
-
Removed:
let c = client () in
2034
-
Removed:
let _ = sign_in_new c in
2035
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
2036
-
Removed:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
2037
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
2038
-
Removed:
let _ =
2039
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
2040
-
Removed:
in
2041
-
Removed:
let token =
2042
-
Removed:
Option.get (csrf_token (body (get c "/workout?slot=1")))
2043
-
Removed:
in
2044
-
Removed:
let _ =
2045
-
Removed:
post c "/workout/slots/1"
2046
-
Removed:
[
2047
-
Removed:
("dream.csrf", token);
2048
-
Removed:
("load", "12");
2049
-
Removed:
("reps", "8");
2050
-
Removed:
("extension", "");
2051
-
Removed:
]
2052
-
Removed:
in
2053
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
2054
-
Removed:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
2055
-
Removed:
(* Slot 1 is filled. Re-posting to its add path is refused. *)
2056
-
Removed:
let token =
2057
-
Removed:
Option.get (csrf_token (body (get c "/logbook/w1?slot=2")))
2058
-
Removed:
in
2059
-
Removed:
let refused =
2060
-
Removed:
post c "/logbook/w1/slots/1"
2061
-
Removed:
[
2062
-
Removed:
("dream.csrf", token);
2063
-
Removed:
("load", "20");
2064
-
Removed:
("reps", "6");
2065
-
Removed:
("extension", "");
2066
-
Removed:
]
2067
-
Removed:
in
2068
-
Removed:
Alcotest.(check int)
2069
-
Removed:
"filled slot is not awaiting" 404 (status refused);
2070
-
Removed:
Alcotest.(check bool)
2071
-
Removed:
"names the slot as not awaiting" true
2072
-
Removed:
(contains ~substring:"not awaiting a record" (body refused)) );
2073
-
Removed:
] );
2074
-
Removed:
( "web.slot_bounds",
2075
-
Removed:
[
2076
-
Removed:
( "posting to a slot beyond the workout is not found",
2077
-
Removed:
`Quick,
2078
-
Removed:
fun () ->
2079
-
Removed:
(* Day 1 has four slots (0..3). Slot 9 does not exist, so the log
2080
-
Removed:
handler answers 404 rather than recording phantom volume. *)
2081
-
Removed:
let c = client () in
2082
-
Removed:
let _ = sign_in_new c in
2083
-
Removed:
let _ = start_day_one c in
2084
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
2085
-
Removed:
let response =
2086
-
Removed:
post c "/workout/slots/9"
2087
-
Removed:
[
2088
-
Removed:
("dream.csrf", token);
2089
-
Removed:
("load", "10");
2090
-
Removed:
("reps", "8");
2091
-
Removed:
("extension", "");
2092
-
Removed:
]
2093
-
Removed:
in
2094
-
Removed:
Alcotest.(check int)
2095
-
Removed:
"out-of-range slot is not found" 404 (status response);
2096
-
Removed:
Alcotest.(check bool)
2097
-
Removed:
"names the slot as not awaiting" true
2098
-
Removed:
(contains ~substring:"not awaiting a record" (body response)) );
2099
-
Removed:
( "editing a slot beyond the workout is not found",
2100
-
Removed:
`Quick,
2101
-
Removed:
fun () ->
2102
-
Removed:
let c = client () in
2103
-
Removed:
let _ = sign_in_new c in
2104
-
Removed:
let _ = start_day_one c in
2105
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
2106
-
Removed:
let response =
2107
-
Removed:
post c "/workout/slots/9/edit"
2108
-
Removed:
[
2109
-
Removed:
("dream.csrf", token);
2110
-
Removed:
("load", "10");
2111
-
Removed:
("reps", "8");
2112
-
Removed:
("extension", "");
2113
-
Removed:
]
2114
-
Removed:
in
2115
-
Removed:
Alcotest.(check int)
2116
-
Removed:
"out-of-range edit is not found" 404 (status response) );
2117
-
Removed:
( "logging with no workout in progress is not found",
2118
-
Removed:
`Quick,
2119
-
Removed:
fun () ->
2120
-
Removed:
let c = client () in
2121
-
Removed:
let _ = sign_in_new c in
2122
-
Removed:
(* No routine selected, no workout begun: the CSRF token comes from
2123
-
Removed:
Home, but the slot route has no workout to record against. *)
2124
-
Removed:
let token = Option.get (csrf_token (body (get c "/"))) in
2125
-
Removed:
let response =
2126
-
Removed:
post c "/workout/slots/0"
2127
-
Removed:
[
2128
-
Removed:
("dream.csrf", token);
2129
-
Removed:
("load", "10");
2130
-
Removed:
("reps", "8");
2131
-
Removed:
("extension", "");
2132
-
Removed:
]
2133
-
Removed:
in
2134
-
Removed:
Alcotest.(check int) "no workout is not found" 404 (status response);
2135
-
Removed:
Alcotest.(check bool)
2136
-
Removed:
"names the absent workout" true
2137
-
Removed:
(contains ~substring:"No workout is in progress" (body response))
2138
-
Removed:
);
2139
-
Removed:
] );
2140
-
Removed:
( "web.duplicate_feedback",
2141
-
Removed:
[
2142
-
Removed:
( "the flow rejects a step that does not match the session's position",
2143
-
Removed:
`Quick,
2144
-
Removed:
fun () ->
2145
-
Removed:
(* The flow advances one factor at a time and stores its position in
2146
-
Removed:
the session. A post whose [step] disagrees with that position —
2147
-
Removed:
a stale form, a double submit, or a crafted body — is refused as
2148
-
Removed:
an invalid form rather than recording a factor twice. This is the
2149
-
Removed:
web tier's guard against a duplicate signal ever reaching the
2150
-
Removed:
domain. *)
2151
-
Removed:
let c = client () in
2152
-
Removed:
let _ = sign_in_new c in
2153
-
Removed:
let start = body (get c "/logbook?feedback=start") in
2154
-
Removed:
let token = Option.get (csrf_token start) in
2155
-
Removed:
(* The session is at step 0; post step 3 instead. *)
2156
-
Removed:
let mismatched =
2157
-
Removed:
post c "/feedback"
2158
-
Removed:
[
2159
-
Removed:
("dream.csrf", token);
2160
-
Removed:
("step", "3");
2161
-
Removed:
("action", "next");
2162
-
Removed:
("choice", "1");
2163
-
Removed:
]
2164
-
Removed:
in
2165
-
Removed:
Alcotest.(check int)
2166
-
Removed:
"mismatched step redirects" 303 (status mismatched);
2167
-
Removed:
let logbook_page = body (get c "/logbook") in
2168
-
Removed:
Alcotest.(check bool)
2169
-
Removed:
"reports the mismatch as an invalid form" true
2170
-
Removed:
(contains ~substring:"The submitted form is not valid."
2171
-
Removed:
logbook_page);
2172
-
Removed:
(* The flow did not advance: it still shows the first factor. *)
2173
-
Removed:
Alcotest.(check bool)
2174
-
Removed:
"the flow stays on the first factor" true
2175
-
Removed:
(contains ~substring:">Sleep</h3>" logbook_page
2176
-
Removed:
&& contains ~substring:"1 of 5 factors" logbook_page) );
2177
-
Removed:
( "answering every factor stores exactly one signal per category",
2178
-
Removed:
`Quick,
2179
-
Removed:
fun () ->
2180
-
Removed:
(* The flow admits one answer per factor, so the stored report holds
2181
-
Removed:
one signal per category and never a duplicate. Walking all five
2182
-
Removed:
factors and saving proves the whole set persists without a
2183
-
Removed:
duplicate-signal rejection. *)
2184
-
Removed:
let c = client () in
2185
-
Removed:
let _ = sign_in_new c in
2186
-
Removed:
let _ = get c "/logbook?feedback=start" in
2187
-
Removed:
let rec advance step =
2188
-
Removed:
if step < 5 then begin
2189
-
Removed:
let token = Option.get (csrf_token (body (get c "/logbook"))) in
2190
-
Removed:
let _ =
2191
-
Removed:
post c "/feedback"
2192
-
Removed:
[
2193
-
Removed:
("dream.csrf", token);
2194
-
Removed:
("step", string_of_int step);
2195
-
Removed:
("action", "next");
2196
-
Removed:
("choice", "1");
2197
-
Removed:
]
2198
-
Removed:
in
2199
-
Removed:
advance (step + 1)
2200
-
Removed:
end
2201
-
Removed:
in
2202
-
Removed:
advance 0;
2203
-
Removed:
let token = Option.get (csrf_token (body (get c "/logbook"))) in
2204
-
Removed:
let saved =
2205
-
Removed:
post c "/feedback"
2206
-
Removed:
[ ("dream.csrf", token); ("step", "5"); ("action", "save") ]
2207
-
Removed:
in
2208
-
Removed:
Alcotest.(check int) "final save redirects" 303 (status saved);
2209
-
Removed:
let logbook_page = body (get c "/logbook") in
2210
-
Removed:
Alcotest.(check bool)
2211
-
Removed:
"stores one signal per distinct factor" true
2212
-
Removed:
(contains ~substring:"Sleep 1 (very poor)" logbook_page
2213
-
Removed:
&& contains ~substring:"Appetite 1 (very poor)" logbook_page
2214
-
Removed:
&& contains ~substring:"Readiness 1 (very poor)" logbook_page
2215
-
Removed:
&& contains ~substring:"Motivation 1 (very poor)" logbook_page
2216
-
Removed:
&& contains ~substring:"Difficulty 1 (very poor)" logbook_page) );
2217
-
Removed:
] );
2218
-
Removed:
( "web.lifecycle",
2219
-
Removed:
[
2220
-
Removed:
( "a full cycle: begin Day 1, record every slot, finish, and see it \
2221
-
Removed:
saved complete",
2222
-
Removed:
`Quick,
2223
-
Removed:
fun () ->
2224
-
Removed:
(* A deep end-to-end pass over the primary flow with a driven clock.
2225
-
Removed:
Begin Day 1, record all four slots, finish, and confirm the
2226
-
Removed:
logbook persists a complete Day 1 with four stimuli. *)
2227
-
Removed:
let c, now = clocked_client () in
2228
-
Removed:
now := day 10;
2229
-
Removed:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
2230
-
Removed:
let _ = start_day_one c in
2231
-
Removed:
(* Fresh workout: nothing recorded yet. *)
2232
-
Removed:
Alcotest.(check bool)
2233
-
Removed:
"opens with no slots recorded" true
2234
-
Removed:
(contains ~substring:"0 of 4 recorded" (body (get c "/workout")));
2235
-
Removed:
record_all_day_one_slots c;
2236
-
Removed:
Alcotest.(check bool)
2237
-
Removed:
"counts all four slots recorded" true
2238
-
Removed:
(contains ~substring:"4 of 4 recorded" (body (get c "/workout")));
2239
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
2240
-
Removed:
let finished = post c "/workout/finish" [ ("dream.csrf", token) ] in
2241
-
Removed:
Alcotest.(check int) "finish redirects" 303 (status finished);
2242
-
Removed:
Alcotest.(check bool)
2243
-
Removed:
"sends the trainee to the logbook feedback prompt" true
2244
-
Removed:
(List.mem "/logbook?prompt=feedback"
2245
-
Removed:
(Dream.headers finished "Location"));
2246
-
Removed:
let logbook_page = body (get c "/logbook") in
2247
-
Removed:
Alcotest.(check bool)
2248
-
Removed:
"the logbook lists the saved Day 1 with all its stimuli" true
2249
-
Removed:
(contains ~substring:"Day 1 — 4 stimuli" logbook_page);
2250
-
Removed:
Alcotest.(check bool)
2251
-
Removed:
"marks the saved workout complete" true
2252
-
Removed:
(contains ~substring:"complete" logbook_page);
2253
-
Removed:
(* The saved workout is durable and reachable at its own URL. *)
2254
-
Removed:
let record_page = body (get c "/logbook/w1") in
2255
-
Removed:
Alcotest.(check bool)
2256
-
Removed:
"the saved workout persists all four slots" true
2257
-
Removed:
(contains ~substring:"4 of 4 recorded" record_page) );
2258
-
Removed:
( "the recovery gate blocks the next workout until enough rest, then \
2259
-
Removed:
Day 2 begins",
2260
-
Removed:
`Quick,
2261
-
Removed:
fun () ->
2262
-
Removed:
(* The deepest flow: after finishing Day 1, the next workout is
2263
-
Removed:
gated until 48 hours of recovery pass. The web tier drives the
2264
-
Removed:
clock, so this proves the gate over HTTP, not just in the
2265
-
Removed:
service. Beginning too early is refused; resting past the
2266
-
Removed:
interval clears the gate and advances the cycle to Day 2. *)
2267
-
Removed:
let c, now = clocked_client () in
2268
-
Removed:
now := day 10;
2269
-
Removed:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
2270
-
Removed:
let _ = start_day_one c in
2271
-
Removed:
record_all_day_one_slots c;
2272
-
Removed:
let token = Option.get (csrf_token (body (get c "/workout"))) in
2273
-
Removed:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
2274
-
Removed:
(* One hour later: recovery is far from complete. Home shows the
2275
-
Removed:
gate and beginning without an override is refused. *)
2276
-
Removed:
now := day 10 + 3600;
2277
-
Removed:
let home = body (get c "/") in
2278
-
Removed:
Alcotest.(check bool)
2279
-
Removed:
"Home shows the recovery gate" true
2280
-
Removed:
(contains ~substring:"requires recovery" home);
2281
-
Removed:
let token = Option.get (csrf_token home) in
2282
-
Removed:
let refused =
2283
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
2284
-
Removed:
in
2285
-
Removed:
Alcotest.(check int)
2286
-
Removed:
"an early begin is bounced back" 303 (status refused);
2287
-
Removed:
Alcotest.(check bool)
2288
-
Removed:
"the early begin returns Home, not the workout" true
2289
-
Removed:
(List.mem "/" (Dream.headers refused "Location"));
2290
-
Removed:
Alcotest.(check int)
2291
-
Removed:
"no workout was started" 303
2292
-
Removed:
(status (get c "/workout"));
2293
-
Removed:
(* Rest two full days: past the 48-hour training interval. The gate
2294
-
Removed:
clears and the next begin succeeds. *)
2295
-
Removed:
now := day 13;
2296
-
Removed:
let home = body (get c "/") in
2297
-
Removed:
Alcotest.(check bool)
2298
-
Removed:
"Home no longer requires recovery" false
2299
-
Removed:
(contains ~substring:"requires recovery" home);
2300
-
Removed:
let token = Option.get (csrf_token home) in
2301
-
Removed:
let began =
2302
-
Removed:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
2303
-
Removed:
in
2304
-
Removed:
Alcotest.(check int) "the rested begin succeeds" 303 (status began);
2305
-
Removed:
Alcotest.(check bool)
2306
-
Removed:
"the rested begin reaches the workout" true
2307
-
Removed:
(List.mem "/workout" (Dream.headers began "Location"));
2308
-
Removed:
let workout = body (get c "/workout") in
2309
-
Removed:
Alcotest.(check bool)
2310
-
Removed:
"the cycle has advanced to Day 2" true
2311
-
Removed:
(contains ~substring:"<h1>Day 2</h1>" workout);
2312
-
Removed:
Alcotest.(check bool)
2313
-
Removed:
"the rested workout shows no early-training notice" false
2314
-
Removed:
(contains ~substring:"Begun before recovery finished." workout) );
2315
-
Removed:
] );
2316
-
Removed:
( "web.isolation",
2317
-
Removed:
[
2318
-
Removed:
( "two trainees never share workout or logbook state",
2319
-
Removed:
`Quick,
2320
-
Removed:
fun () ->
2321
-
Removed:
(* Two trainees on the same store, each with their own cookie jar.
2322
-
Removed:
One begins and finishes a workout; the other must see none of it:
2323
-
Removed:
not the in-progress workout, not the saved record. This proves
2324
-
Removed:
the per-trainee scoping the service promises, over HTTP. *)
2325
-
Removed:
let alice = client () in
2326
-
Removed:
let _ = register alice ~username:"alice" ~password:"heavyduty1" in
2327
-
Removed:
let bob = { alice with jar = [] } in
2328
-
Removed:
let _ = register bob ~username:"bobby" ~password:"heavyduty1" in
2329
-
Removed:
(* Alice selects and begins a workout, records a slot. *)
2330
-
Removed:
let token = Option.get (csrf_token (body (get alice "/"))) in
2331
-
Removed:
let _ =
2332
-
Removed:
post alice "/routines/ideal/select" [ ("dream.csrf", token) ]
2333
-
Removed:
in
2334
-
Removed:
let token = Option.get (csrf_token (body (get alice "/"))) in
2335
-
Removed:
let _ =
2336
-
Removed:
post alice "/workout"
2337
-
Removed:
[ ("dream.csrf", token); ("override", "false") ]
2338
-
Removed:
in
2339
-
Removed:
let token =
2340
-
Removed:
Option.get (csrf_token (body (get alice "/workout?slot=1")))
2341
-
Removed:
in
2342
-
Removed:
let _ =
2343
-
Removed:
post alice "/workout/slots/1"
2344
-
Removed:
[
2345
-
Removed:
("dream.csrf", token);
2346
-
Removed:
("load", "12");
2347
-
Removed:
("reps", "8");
2348
-
Removed:
("extension", "");
2349
-
Removed:
]
2350
-
Removed:
in
2351
-
Removed:
(* Bob has no workout in progress: his workout view redirects. *)
2352
-
Removed:
Alcotest.(check int)
2353
-
Removed:
"Bob has no workout in progress" 303
2354
-
Removed:
(status (get bob "/workout"));
2355
-
Removed:
(* Bob's Home does not show Alice's in-progress card. *)
2356
-
Removed:
let bob_home = body (get bob "/") in
2357
-
Removed:
Alcotest.(check bool)
2358
-
Removed:
"Bob's Home has no in-progress card" false
2359
-
Removed:
(contains ~substring:"in-progress-card" bob_home);
2360
-
Removed:
(* Alice finishes; her record becomes w1 for her. *)
2361
-
Removed:
let token = Option.get (csrf_token (body (get alice "/workout"))) in
2362
-
Removed:
let _ = post alice "/workout/finish" [ ("dream.csrf", token) ] in
2363
-
Removed:
let alice_logbook = body (get alice "/logbook") in
2364
-
Removed:
Alcotest.(check bool)
2365
-
Removed:
"Alice sees her saved workout" true
2366
-
Removed:
(contains ~substring:"/logbook/w1" alice_logbook);
2367
-
Removed:
(* Bob's logbook is empty and Alice's record is not reachable from
2368
-
Removed:
Bob's session. Ids are per-trainee, so Bob's own w1 does not
2369
-
Removed:
exist yet. *)
2370
-
Removed:
let bob_logbook = body (get bob "/logbook") in
2371
-
Removed:
Alcotest.(check bool)
2372
-
Removed:
"Bob's logbook shows nothing logged" true
2373
-
Removed:
(contains ~substring:"Nothing logged yet." bob_logbook);
2374
-
Removed:
Alcotest.(check bool)
2375
-
Removed:
"Bob's logbook does not list Alice's record" false
2376
-
Removed:
(contains ~substring:"Day 1 — " bob_logbook);
6
+
Added:
open Hito_app
7
+
Added:
open Hito_persistence
8
+
Added:
module Handlers = Hito_web.Handlers.Make (Memory_repo) (Bcrypt_hash)
9
+
Added:
10
+
Added:
(* A fresh, fully wired application: secret, in-memory sessions, routes.
11
+
Added:
Registration is opened here so the auth-flow tests can create trainees via
12
+
Added:
[/register]; production keeps it closed. *)
13
+
Added:
let app () =
14
+
Added:
let handlers =
15
+
Added:
Handlers.make ~repo:(Memory_repo.create ()) ~registration_open:true ()
16
+
Added:
in
17
+
Added:
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers)
18
+
Added:
|> fun handler -> Dream.set_secret "test-secret-value" handler
19
+
Added:
20
+
Added:
let status response = Dream.status response |> Dream.status_to_int
21
+
Added:
let body response = Lwt_main.run (Dream.body response)
22
+
Added:
23
+
Added:
(* The redirect target of a 3xx response — the URL half of a URL-to-URL flow. *)
24
+
Added:
let location response =
25
+
Added:
match Dream.header response "Location" with
26
+
Added:
| Some target -> target
27
+
Added:
| None -> Alcotest.failf "expected a Location header on a redirect"
28
+
Added:
29
+
Added:
(* --- a cookie-carrying client --- *)
30
+
Added:
31
+
Added:
(* Extract cookies (name=value) from all Set-Cookie response headers. *)
32
+
Added:
let cookies_of response =
33
+
Added:
Dream.headers response "Set-Cookie"
34
+
Added:
|> List.filter_map (fun sc ->
35
+
Added:
match String.index_opt sc ';' with
36
+
Added:
| Some i -> Some (String.sub sc 0 i)
37
+
Added:
| None -> Some sc)
38
+
Added:
39
+
Added:
type client = { app : Dream.handler; mutable jar : string list }
40
+
Added:
41
+
Added:
let client () = { app = app (); jar = [] }
42
+
Added:
43
+
Added:
let cookie_header client =
44
+
Added:
if client.jar = [] then [] else [ ("Cookie", String.concat "; " client.jar) ]
45
+
Added:
46
+
Added:
let remember client response =
47
+
Added:
(* Replace cookies of the same name; keep the rest. *)
48
+
Added:
List.iter
49
+
Added:
(fun fresh ->
50
+
Added:
let name =
51
+
Added:
match String.index_opt fresh '=' with
52
+
Added:
| Some i -> String.sub fresh 0 i
53
+
Added:
| None -> fresh
54
+
Added:
in
55
+
Added:
client.jar <-
56
+
Added:
fresh
57
+
Added:
:: List.filter
58
+
Added:
(fun old ->
59
+
Added:
not
60
+
Added:
(String.length old >= String.length name
61
+
Added:
&& String.sub old 0 (String.length name) = name
62
+
Added:
&& (String.length old = String.length name
63
+
Added:
|| old.[String.length name] = '=')))
64
+
Added:
client.jar)
65
+
Added:
(cookies_of response)
66
+
Added:
67
+
Added:
let get client target =
68
+
Added:
let response =
69
+
Added:
Dream.test client.app
70
+
Added:
(Dream.request ~method_:`GET ~target ~headers:(cookie_header client) "")
71
+
Added:
in
72
+
Added:
remember client response;
73
+
Added:
response
74
+
Added:
75
+
Added:
let post client target fields =
76
+
Added:
let body =
77
+
Added:
fields
78
+
Added:
|> List.map (fun (k, v) ->
79
+
Added:
Dream.to_percent_encoded k ^ "=" ^ Dream.to_percent_encoded v)
80
+
Added:
|> String.concat "&"
81
+
Added:
in
82
+
Added:
let response =
83
+
Added:
Dream.test client.app
84
+
Added:
(Dream.request ~method_:`POST ~target
85
+
Added:
~headers:
86
+
Added:
(("Content-Type", "application/x-www-form-urlencoded")
87
+
Added:
:: cookie_header client)
88
+
Added:
body)
89
+
Added:
in
90
+
Added:
remember client response;
91
+
Added:
response
92
+
Added:
93
+
Added:
(* A tiny substring search, since Str is not a dependency here. *)
94
+
Added:
let index_from ~needle haystack start =
95
+
Added:
let n = String.length needle and h = String.length haystack in
96
+
Added:
let rec at i =
97
+
Added:
if i + n > h then None
98
+
Added:
else if String.sub haystack i n = needle then Some i
99
+
Added:
else at (i + 1)
100
+
Added:
in
101
+
Added:
if start < 0 then None else at start
102
+
Added:
103
+
Added:
(* Pull the CSRF token value out of a rendered form. *)
104
+
Added:
let csrf_token html =
105
+
Added:
match index_from ~needle:"name=\"dream.csrf\"" html 0 with
106
+
Added:
| None -> None
107
+
Added:
| Some idx -> (
108
+
Added:
match index_from ~needle:"value=\"" html idx with
109
+
Added:
| None -> None
110
+
Added:
| Some v ->
111
+
Added:
let start = v + String.length "value=\"" in
112
+
Added:
let stop = String.index_from html start '"' in
113
+
Added:
Some (String.sub html start (stop - start)))
114
+
Added:
115
+
Added:
let register client ~username ~password =
116
+
Added:
let page = body (get client "/register") in
117
+
Added:
let token = Option.get (csrf_token page) in
118
+
Added:
post client "/register"
119
+
Added:
[ ("dream.csrf", token); ("username", username); ("password", password) ]
120
+
Added:
121
+
Added:
let sign_in_new client =
122
+
Added:
register client ~username:"lifter" ~password:"heavyduty1"
123
+
Added:
124
+
Added:
(* Sign in through the credential form, as a returning trainee would. Unlike
125
+
Added:
[register], this exercises the [POST /login] path. *)
126
+
Added:
let sign_in client ~username ~password =
127
+
Added:
let page = body (get client "/login") in
128
+
Added:
let token = Option.get (csrf_token page) in
129
+
Added:
post client "/login"
130
+
Added:
[ ("dream.csrf", token); ("username", username); ("password", password) ]
131
+
Added:
132
+
Added:
(* An application whose clock the test drives. A shared [Memory_repo] and a
133
+
Added:
mutable [now] cell let a deep test advance time across the recovery gate,
134
+
Added:
which the wall-clock default cannot. Registration is open so the test can
135
+
Added:
create the trainee it drives. Also hands back the repository, so a deep test
136
+
Added:
can assert the domain state its flow produced. *)
137
+
Added:
let clocked_client () =
138
+
Added:
let now = ref 0 in
139
+
Added:
let repo = Memory_repo.create () in
140
+
Added:
let handlers =
141
+
Added:
Handlers.make ~repo
142
+
Added:
~now:(fun () -> Recovery.timestamp_of_unix_seconds !now)
143
+
Added:
~registration_open:true ()
144
+
Added:
in
145
+
Added:
let app =
146
+
Added:
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h ->
147
+
Added:
Dream.set_secret "test-secret-value" h
148
+
Added:
in
149
+
Added:
({ app; jar = [] }, now, repo)
150
+
Added:
151
+
Added:
let day n = n * 86_400
152
+
Added:
153
+
Added:
(* A client that hands back its repository, so a test can assert the domain
154
+
Added:
state the flow produced — the log, the stored feedback, the renamed account —
155
+
Added:
rather than reading it back out of rendered HTML. *)
156
+
Added:
let client_with_repo () =
157
+
Added:
let repo = Memory_repo.create () in
158
+
Added:
let handlers = Handlers.make ~repo ~registration_open:true () in
159
+
Added:
let app =
160
+
Added:
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h ->
161
+
Added:
Dream.set_secret "test-secret-value" h
162
+
Added:
in
163
+
Added:
({ app; jar = [] }, repo)
164
+
Added:
165
+
Added:
(* Look up the sole seeded trainee by the name the flow helpers register. *)
166
+
Added:
let the_trainee repo ~username =
167
+
Added:
match Trainee.username username with
168
+
Added:
| Error _ -> Alcotest.failf "test username %S is invalid" username
169
+
Added:
| Ok u -> (
170
+
Added:
match Lwt_main.run (Memory_repo.find_trainee_by_username repo u) with
171
+
Added:
| Some trainee -> trainee
172
+
Added:
| None -> Alcotest.failf "no trainee named %S" username)
173
+
Added:
174
+
Added:
(* Drive a trainee to the start of a fresh Day 1 workout: select the ideal
175
+
Added:
routine and begin without an override. Returns the client. *)
176
+
Added:
let start_day_one c =
177
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
178
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
179
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
180
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
181
+
Added:
182
+
Added:
(* Record every slot of the in-progress Day 1 workout, one at a time, addressed
183
+
Added:
by [?slot=]. Slot 0 is the flyes/incline-press pair; slot 3 is the
184
+
Added:
french-press/dips pair; slots 1 and 2 are single lateral movements. *)
185
+
Added:
let record_all_day_one_slots c =
186
+
Added:
let record_pair slot comp_load =
187
+
Added:
let token =
188
+
Added:
Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot))))
189
+
Added:
in
190
+
Added:
post c ("/workout/slots/" ^ slot)
191
+
Added:
[
192
+
Added:
("dream.csrf", token);
193
+
Added:
("iso_load", "20");
194
+
Added:
("iso_reps", "9");
195
+
Added:
("comp_load", comp_load);
196
+
Added:
("comp_reps", "7");
197
+
Added:
("extension", "");
198
+
Added:
]
199
+
Added:
in
200
+
Added:
let record_single slot load reps =
201
+
Added:
let token =
202
+
Added:
Option.get (csrf_token (body (get c ("/workout?slot=" ^ slot))))
203
+
Added:
in
204
+
Added:
post c ("/workout/slots/" ^ slot)
205
+
Added:
[
206
+
Added:
("dream.csrf", token); ("load", load); ("reps", reps); ("extension", "");
207
+
Added:
]
208
+
Added:
in
209
+
Added:
let _ = record_pair "0" "60" in
210
+
Added:
let _ = record_single "1" "12" "8" in
211
+
Added:
let _ = record_single "2" "10" "9" in
212
+
Added:
let _ = record_pair "3" "0" in
213
+
Added:
()
214
+
Added:
215
+
Added:
let route_tests =
216
+
Added:
[
217
+
Added:
( "web.auth",
218
+
Added:
[
219
+
Added:
( "an unauthenticated visit to the overview redirects to sign-in",
220
+
Added:
`Quick,
221
+
Added:
fun () ->
222
+
Added:
let c = client () in
223
+
Added:
let response = get c "/" in
224
+
Added:
Alcotest.(check int) "redirect" 303 (status response);
225
+
Added:
Alcotest.(check string) "to /login" "/login" (location response) );
226
+
Added:
( "the sign-in page is served",
227
+
Added:
`Quick,
228
+
Added:
fun () ->
229
+
Added:
let c = client () in
230
+
Added:
Alcotest.(check int) "GET /login" 200 (status (get c "/login")) );
231
+
Added:
( "registration creates the account, signs in, and reaches the overview",
232
+
Added:
`Quick,
233
+
Added:
fun () ->
234
+
Added:
let c, repo = client_with_repo () in
235
+
Added:
let registered =
236
+
Added:
register c ~username:"lifter" ~password:"heavyduty1"
237
+
Added:
in
238
+
Added:
Alcotest.(check int)
239
+
Added:
"registered, redirected" 303 (status registered);
240
+
Added:
(* The account now exists: the flow orchestrated a real create. *)
241
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
242
+
Added:
Alcotest.(check string)
243
+
Added:
"stored under the normalized username" "lifter"
244
+
Added:
(Trainee.username_to_string trainee.Trainee.username);
245
+
Added:
(* The session authenticates the follow-up request. *)
246
+
Added:
Alcotest.(check int)
247
+
Added:
"authenticated overview" 200
248
+
Added:
(status (get c "/")) );
249
+
Added:
( "an authenticated trainee reaches the overview",
250
+
Added:
`Quick,
251
+
Added:
fun () ->
252
+
Added:
let c = client () in
253
+
Added:
let _ = sign_in_new c in
254
+
Added:
Alcotest.(check int) "home" 200 (status (get c "/")) );
255
+
Added:
( "sign-out clears the session",
256
+
Added:
`Quick,
257
+
Added:
fun () ->
258
+
Added:
let c = client () in
259
+
Added:
let _ = sign_in_new c in
260
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
261
+
Added:
let out = post c "/logout" [ ("dream.csrf", token) ] in
262
+
Added:
Alcotest.(check int) "logout redirects" 303 (status out);
263
+
Added:
(* The session is gone: the overview again bounces to sign-in. *)
264
+
Added:
let after = get c "/" in
265
+
Added:
Alcotest.(check int) "overview now redirects" 303 (status after);
266
+
Added:
Alcotest.(check string) "to /login" "/login" (location after) );
267
+
Added:
( "a form post without a CSRF token is refused and changes nothing",
268
+
Added:
`Quick,
269
+
Added:
fun () ->
270
+
Added:
let c, repo = client_with_repo () in
271
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
272
+
Added:
let response =
273
+
Added:
post c "/routines/ideal/select" [ (* no dream.csrf *) ]
274
+
Added:
in
275
+
Added:
Alcotest.(check int)
276
+
Added:
"redirects after the refused post" 303 (status response);
277
+
Added:
(* No routine was selected: the refused post had no domain effect. *)
278
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
279
+
Added:
Alcotest.(check bool)
280
+
Added:
"no active routine" true
281
+
Added:
(Option.is_none
282
+
Added:
(Lwt_main.run
283
+
Added:
(Memory_repo.active_routine repo trainee.Trainee.id))) );
284
+
Added:
( "a too-short username is refused",
285
+
Added:
`Quick,
286
+
Added:
fun () ->
287
+
Added:
let c, repo = client_with_repo () in
288
+
Added:
let response = register c ~username:"abc" ~password:"heavyduty1" in
289
+
Added:
Alcotest.(check int) "rejected" 400 (status response);
290
+
Added:
Alcotest.(check (option string))
291
+
Added:
"no account created" None
292
+
Added:
(Lwt_main.run
293
+
Added:
(match Trainee.username "abc" with
294
+
Added:
| Ok u -> Memory_repo.find_trainee_by_username repo u
295
+
Added:
| Error _ -> Lwt.return None)
296
+
Added:
|> Option.map (fun t -> Trainee.id_to_string t.Trainee.id)) );
297
+
Added:
( "a too-long username is refused",
298
+
Added:
`Quick,
299
+
Added:
fun () ->
300
+
Added:
let c = client () in
301
+
Added:
let response =
302
+
Added:
register c ~username:"abcdefghijklmnopqrstu"
303
+
Added:
~password:"heavyduty1"
304
+
Added:
in
305
+
Added:
Alcotest.(check int) "rejected" 400 (status response) );
306
+
Added:
( "a short password is accepted (passwords carry no policy)",
307
+
Added:
`Quick,
308
+
Added:
fun () ->
309
+
Added:
let c = client () in
310
+
Added:
let response = register c ~username:"lifter" ~password:"x" in
311
+
Added:
Alcotest.(check int) "registered, redirected" 303 (status response);
312
+
Added:
Alcotest.(check int)
313
+
Added:
"authenticated overview" 200
314
+
Added:
(status (get c "/")) );
315
+
Added:
( "with registration closed the register routes are absent",
316
+
Added:
`Quick,
317
+
Added:
fun () ->
318
+
Added:
(* The production default: [registration_open] unset. *)
319
+
Added:
let handlers = Handlers.make ~repo:(Memory_repo.create ()) () in
320
+
Added:
let app =
321
+
Added:
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers)
322
+
Added:
|> fun h -> Dream.set_secret "test-secret-value" h
323
+
Added:
in
324
+
Added:
let c = { app; jar = [] } in
325
+
Added:
Alcotest.(check int)
326
+
Added:
"GET /register is not found" 404
327
+
Added:
(status (get c "/register"));
328
+
Added:
Alcotest.(check int)
329
+
Added:
"GET /login is served" 200
330
+
Added:
(status (get c "/login")) );
331
+
Added:
] );
332
+
Added:
( "web.flow",
333
+
Added:
[
334
+
Added:
( "selecting a routine redirects and records the active routine",
335
+
Added:
`Quick,
336
+
Added:
fun () ->
337
+
Added:
(* POST-redirect-GET: selecting a routine answers 303, and the
338
+
Added:
redirected GET is served. The domain effect is what matters: the
339
+
Added:
trainee now has an active routine. *)
340
+
Added:
let c, repo = client_with_repo () in
341
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
342
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
343
+
Added:
Alcotest.(check bool)
344
+
Added:
"no active routine before selection" true
345
+
Added:
(Option.is_none
346
+
Added:
(Lwt_main.run
347
+
Added:
(Memory_repo.active_routine repo trainee.Trainee.id)));
348
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
349
+
Added:
let selection =
350
+
Added:
post c "/routines/ideal/select" [ ("dream.csrf", token) ]
351
+
Added:
in
352
+
Added:
Alcotest.(check int) "selection redirects" 303 (status selection);
353
+
Added:
Alcotest.(check bool)
354
+
Added:
"a routine is now active" true
355
+
Added:
(Option.is_some
356
+
Added:
(Lwt_main.run
357
+
Added:
(Memory_repo.active_routine repo trainee.Trainee.id)));
358
+
Added:
(* The redirected GET is served. *)
359
+
Added:
Alcotest.(check int)
360
+
Added:
"the redirected GET is 200" 200
361
+
Added:
(status (get c "/")) );
362
+
Added:
( "a signed-in trainee selects a routine and begins a workout",
363
+
Added:
`Quick,
364
+
Added:
fun () ->
365
+
Added:
let c, repo = client_with_repo () in
366
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
367
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
368
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
369
+
Added:
let selection =
370
+
Added:
post c "/routines/ideal/select" [ ("dream.csrf", token) ]
371
+
Added:
in
372
+
Added:
Alcotest.(check int) "routine selected" 303 (status selection);
373
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
374
+
Added:
let started =
375
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
376
+
Added:
in
377
+
Added:
Alcotest.(check int) "workout started" 303 (status started);
378
+
Added:
(* The workout is now in progress in the domain, and its view is
379
+
Added:
served. *)
380
+
Added:
Alcotest.(check bool)
381
+
Added:
"a workout is in progress" true
382
+
Added:
(Option.is_some
383
+
Added:
(Lwt_main.run
384
+
Added:
(Memory_repo.in_progress repo trainee.Trainee.id)));
385
+
Added:
Alcotest.(check int)
386
+
Added:
"the workout view is served" 200
387
+
Added:
(status (get c "/workout"));
388
+
Added:
(* The static workout client asset is served. *)
389
+
Added:
let asset = get c "/assets/workout-client.js" in
390
+
Added:
Alcotest.(check int) "serves the workout client" 200 (status asset)
391
+
Added:
);
392
+
Added:
( "the routine page is served once a routine is selected",
393
+
Added:
`Quick,
394
+
Added:
fun () ->
395
+
Added:
let c = client () in
396
+
Added:
let _ = sign_in_new c in
397
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
398
+
Added:
let selection =
399
+
Added:
post c "/routines/ideal/select" [ ("dream.csrf", token) ]
400
+
Added:
in
401
+
Added:
Alcotest.(check int) "routine selected" 303 (status selection);
402
+
Added:
Alcotest.(check int)
403
+
Added:
"routine page served" 200
404
+
Added:
(status (get c "/routine")) );
405
+
Added:
( "a recorded slot can be corrected, replacing it rather than adding \
406
+
Added:
volume",
407
+
Added:
`Quick,
408
+
Added:
fun () ->
409
+
Added:
let c, repo = client_with_repo () in
410
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
411
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
412
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
413
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
414
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
415
+
Added:
let _ =
416
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
417
+
Added:
in
418
+
Added:
(* Slot 1 is the single Laterals set. Record it, then correct it —
419
+
Added:
one slot at a time, addressed by [?slot=]. *)
420
+
Added:
let token =
421
+
Added:
Option.get (csrf_token (body (get c "/workout?slot=1")))
422
+
Added:
in
423
+
Added:
let recorded =
424
+
Added:
post c "/workout/slots/1"
425
+
Added:
[
426
+
Added:
("dream.csrf", token);
427
+
Added:
("load", "12");
428
+
Added:
("reps", "8");
429
+
Added:
("extension", "");
430
+
Added:
]
431
+
Added:
in
432
+
Added:
Alcotest.(check int) "recorded" 303 (status recorded);
433
+
Added:
let filled_after_record () =
434
+
Added:
match
435
+
Added:
Lwt_main.run (Memory_repo.in_progress repo trainee.Trainee.id)
436
+
Added:
with
437
+
Added:
| Some w -> Evidence.Workout.filled_slots w
438
+
Added:
| None -> Alcotest.failf "expected a workout in progress"
439
+
Added:
in
440
+
Added:
Alcotest.(check int) "one filled slot" 1 (filled_after_record ());
441
+
Added:
let token =
442
+
Added:
Option.get (csrf_token (body (get c "/workout?slot=1")))
443
+
Added:
in
444
+
Added:
let corrected =
445
+
Added:
post c "/workout/slots/1/edit"
446
+
Added:
[
447
+
Added:
("dream.csrf", token);
448
+
Added:
("load", "16");
449
+
Added:
("reps", "6");
450
+
Added:
("extension", "");
451
+
Added:
]
452
+
Added:
in
453
+
Added:
Alcotest.(check int) "corrected" 303 (status corrected);
454
+
Added:
(* A correction replaces the slot; it does not add volume. *)
455
+
Added:
Alcotest.(check int)
456
+
Added:
"still one filled slot, not two" 1 (filled_after_record ()) );
457
+
Added:
( "a saved workout's slot can be corrected from the logbook view",
458
+
Added:
`Quick,
459
+
Added:
fun () ->
460
+
Added:
let c, repo = client_with_repo () in
461
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
462
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
463
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
464
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
465
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
466
+
Added:
let _ =
467
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
468
+
Added:
in
469
+
Added:
let token =
470
+
Added:
Option.get (csrf_token (body (get c "/workout?slot=1")))
471
+
Added:
in
472
+
Added:
let _ =
473
+
Added:
post c "/workout/slots/1"
474
+
Added:
[
475
+
Added:
("dream.csrf", token);
476
+
Added:
("load", "12");
477
+
Added:
("reps", "8");
478
+
Added:
("extension", "");
479
+
Added:
]
480
+
Added:
in
481
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
482
+
Added:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
483
+
Added:
(* The one saved workout is w1 for this trainee. Correct its slot
484
+
Added:
through the logbook path, then confirm the store still has one
485
+
Added:
filled slot — a correction, not extra volume. *)
486
+
Added:
let record_page = body (get c "/logbook/w1?slot=1") in
487
+
Added:
let token = Option.get (csrf_token record_page) in
488
+
Added:
let corrected =
489
+
Added:
post c "/logbook/w1/slots/1/edit"
490
+
Added:
[
491
+
Added:
("dream.csrf", token);
492
+
Added:
("load", "20");
493
+
Added:
("reps", "7");
494
+
Added:
("extension", "");
495
+
Added:
]
496
+
Added:
in
497
+
Added:
Alcotest.(check int) "corrected" 303 (status corrected);
498
+
Added:
let record =
499
+
Added:
Lwt_main.run
500
+
Added:
(Memory_repo.find repo trainee.Trainee.id
501
+
Added:
(Repository.workout_id "w1"))
502
+
Added:
in
503
+
Added:
Alcotest.(check int)
504
+
Added:
"still one filled slot" 1
505
+
Added:
(match record with
506
+
Added:
| Some r -> Evidence.Workout.filled_slots r.Repository.workout
507
+
Added:
| None -> Alcotest.failf "expected the saved workout w1") );
508
+
Added:
( "an invalid workout field is rejected and records nothing",
509
+
Added:
`Quick,
510
+
Added:
fun () ->
511
+
Added:
let c, repo = client_with_repo () in
512
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
513
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
514
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
515
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
516
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
517
+
Added:
let _ =
518
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
519
+
Added:
in
520
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
521
+
Added:
let invalid =
522
+
Added:
post c "/workout/slots/0"
523
+
Added:
[
524
+
Added:
("dream.csrf", token);
525
+
Added:
("iso_load", "not-a-number");
526
+
Added:
("iso_reps", "8");
527
+
Added:
("comp_load", "40");
528
+
Added:
("comp_reps", "6");
529
+
Added:
("extension", "");
530
+
Added:
]
531
+
Added:
in
532
+
Added:
Alcotest.(check int) "invalid field redirects" 303 (status invalid);
533
+
Added:
(* The bad input recorded no slot. *)
534
+
Added:
Alcotest.(check int)
535
+
Added:
"no slot was filled" 0
536
+
Added:
(match
537
+
Added:
Lwt_main.run (Memory_repo.in_progress repo trainee.Trainee.id)
538
+
Added:
with
539
+
Added:
| Some w -> Evidence.Workout.filled_slots w
540
+
Added:
| None -> Alcotest.failf "expected a workout in progress") );
541
+
Added:
( "recording a slot advances the count of filled slots",
542
+
Added:
`Quick,
543
+
Added:
fun () ->
544
+
Added:
let c, repo = client_with_repo () in
545
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
546
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
547
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
548
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
549
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
550
+
Added:
let _ =
551
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
552
+
Added:
in
553
+
Added:
let filled () =
554
+
Added:
match
555
+
Added:
Lwt_main.run (Memory_repo.in_progress repo trainee.Trainee.id)
556
+
Added:
with
557
+
Added:
| Some w -> Evidence.Workout.filled_slots w
558
+
Added:
| None -> Alcotest.failf "expected a workout in progress"
559
+
Added:
in
560
+
Added:
Alcotest.(check int) "nothing recorded yet" 0 (filled ());
561
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
562
+
Added:
let recorded =
563
+
Added:
post c "/workout/slots/0"
564
+
Added:
[
565
+
Added:
("dream.csrf", token);
566
+
Added:
("iso_load", "10");
567
+
Added:
("iso_reps", "8");
568
+
Added:
("comp_load", "40");
569
+
Added:
("comp_reps", "6");
570
+
Added:
("extension", "");
571
+
Added:
]
572
+
Added:
in
573
+
Added:
Alcotest.(check int) "recorded slot 0" 303 (status recorded);
574
+
Added:
Alcotest.(check int) "one slot filled" 1 (filled ()) );
575
+
Added:
( "signing out returns the trainee to the sign-in page",
576
+
Added:
`Quick,
577
+
Added:
fun () ->
578
+
Added:
let c = client () in
579
+
Added:
let _ = sign_in_new c in
580
+
Added:
let page = body (get c "/") in
581
+
Added:
let token = Option.get (csrf_token page) in
582
+
Added:
let out = post c "/logout" [ ("dream.csrf", token) ] in
583
+
Added:
Alcotest.(check int) "logout redirect" 303 (status out);
584
+
Added:
let after = get c "/" in
585
+
Added:
Alcotest.(check int) "overview now redirects" 303 (status after);
586
+
Added:
Alcotest.(check string) "to /login" "/login" (location after) );
587
+
Added:
( "cancelling a workout without a CSRF token leaves it in progress",
588
+
Added:
`Quick,
589
+
Added:
fun () ->
590
+
Added:
let c, repo = client_with_repo () in
591
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
592
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
593
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
594
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
595
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
596
+
Added:
let _ =
597
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
598
+
Added:
in
599
+
Added:
let response = post c "/workout/cancel" [ (* no dream.csrf *) ] in
600
+
Added:
(* Cancelling always navigates away to Home. A forged or missing
601
+
Added:
token skips the discard, so the workout remains in progress. *)
602
+
Added:
Alcotest.(check int)
603
+
Added:
"navigates away without an error page" 303 (status response);
604
+
Added:
Alcotest.(check bool)
605
+
Added:
"returns Home" true
606
+
Added:
(List.mem "/" (Dream.headers response "Location"));
607
+
Added:
Alcotest.(check bool)
608
+
Added:
"workout still in progress" true
609
+
Added:
(Option.is_some
610
+
Added:
(Lwt_main.run
611
+
Added:
(Memory_repo.in_progress repo trainee.Trainee.id))) );
612
+
Added:
( "cancelling after recording a set discards the workout entirely",
613
+
Added:
`Quick,
614
+
Added:
fun () ->
615
+
Added:
let c, repo = client_with_repo () in
616
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
617
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
618
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
619
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
620
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
621
+
Added:
let _ =
622
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
623
+
Added:
in
624
+
Added:
let token =
625
+
Added:
Option.get (csrf_token (body (get c "/workout?slot=1")))
626
+
Added:
in
627
+
Added:
let recorded =
628
+
Added:
post c "/workout/slots/1"
629
+
Added:
[
630
+
Added:
("dream.csrf", token);
631
+
Added:
("load", "12");
632
+
Added:
("reps", "8");
633
+
Added:
("extension", "");
634
+
Added:
]
635
+
Added:
in
636
+
Added:
Alcotest.(check int) "recorded a set" 303 (status recorded);
637
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
638
+
Added:
let cancelled =
639
+
Added:
post c "/workout/cancel" [ ("dream.csrf", token) ]
640
+
Added:
in
641
+
Added:
Alcotest.(check int) "cancel returns Home" 303 (status cancelled);
642
+
Added:
Alcotest.(check bool)
643
+
Added:
"returns Home, not the logbook" true
644
+
Added:
(List.mem "/" (Dream.headers cancelled "Location"));
645
+
Added:
(* No current workout, and no record: an abandoned session is not
646
+
Added:
evidence. *)
647
+
Added:
Alcotest.(check bool)
648
+
Added:
"no workout in progress" true
649
+
Added:
(Option.is_none
650
+
Added:
(Lwt_main.run
651
+
Added:
(Memory_repo.in_progress repo trainee.Trainee.id)));
652
+
Added:
Alcotest.(check int)
653
+
Added:
"history is empty" 0
654
+
Added:
(List.length
655
+
Added:
(Lwt_main.run (Memory_repo.history repo trainee.Trainee.id)));
656
+
Added:
Alcotest.(check int)
657
+
Added:
"the discarded workout has no record" 404
658
+
Added:
(status (get c "/logbook/w1")) );
659
+
Added:
( "sequential feedback stores only answered factors, never skipped ones",
660
+
Added:
`Quick,
661
+
Added:
fun () ->
662
+
Added:
let c, repo = client_with_repo () in
663
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
664
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
665
+
Added:
let _ = get c "/logbook?feedback=start" in
666
+
Added:
(* Answer sleep (1), skip appetite, answer readiness (3), answer
667
+
Added:
motivation (5), skip difficulty. *)
668
+
Added:
let step_action step action extra =
669
+
Added:
let token = Option.get (csrf_token (body (get c "/logbook"))) in
670
+
Added:
post c "/feedback"
671
+
Added:
(("dream.csrf", token)
672
+
Added:
:: ("step", string_of_int step)
673
+
Added:
:: ("action", action) :: extra)
674
+
Added:
in
675
+
Added:
let s0 = step_action 0 "next" [ ("choice", "1") ] in
676
+
Added:
Alcotest.(check int) "first factor redirects" 303 (status s0);
677
+
Added:
let _ = step_action 1 "skip" [] in
678
+
Added:
let _ = step_action 2 "next" [ ("choice", "3") ] in
679
+
Added:
let _ = step_action 3 "next" [ ("choice", "5") ] in
680
+
Added:
let _ = step_action 4 "skip" [] in
681
+
Added:
let saved = step_action 5 "save" [ ("pain", "true") ] in
682
+
Added:
Alcotest.(check int) "final feedback redirects" 303 (status saved);
683
+
Added:
(* Exactly one report is stored, holding only the answered signals
684
+
Added:
plus pain, and none of the skipped factors. *)
685
+
Added:
let reports =
686
+
Added:
Lwt_main.run (Memory_repo.feedback repo trainee.Trainee.id)
687
+
Added:
in
688
+
Added:
Alcotest.(check int)
689
+
Added:
"one feedback report stored" 1 (List.length reports);
690
+
Added:
let signals = Evidence.Feedback.signals (List.hd reports) in
691
+
Added:
let has p = List.exists p signals in
692
+
Added:
let open Evidence.Feedback in
693
+
Added:
Alcotest.(check bool)
694
+
Added:
"stored sleep" true
695
+
Added:
(has (function Sleep _ -> true | _ -> false));
696
+
Added:
Alcotest.(check bool)
697
+
Added:
"stored readiness" true
698
+
Added:
(has (function Readiness _ -> true | _ -> false));
699
+
Added:
Alcotest.(check bool)
700
+
Added:
"stored motivation" true
701
+
Added:
(has (function Motivation _ -> true | _ -> false));
702
+
Added:
Alcotest.(check bool)
703
+
Added:
"stored pain" true
704
+
Added:
(has (function Pain -> true | _ -> false));
705
+
Added:
Alcotest.(check bool)
706
+
Added:
"did not store skipped appetite" false
707
+
Added:
(has (function Appetite _ -> true | _ -> false));
708
+
Added:
Alcotest.(check bool)
709
+
Added:
"did not store skipped difficulty" false
710
+
Added:
(has (function Difficulty _ -> true | _ -> false)) );
711
+
Added:
( "skipping the entire feedback flow records nothing",
712
+
Added:
`Quick,
713
+
Added:
fun () ->
714
+
Added:
let c, repo = client_with_repo () in
715
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
716
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
717
+
Added:
let start = body (get c "/logbook?feedback=start") in
718
+
Added:
let token = Option.get (csrf_token start) in
719
+
Added:
let skipped = post c "/feedback/cancel" [ ("dream.csrf", token) ] in
720
+
Added:
Alcotest.(check int) "full-flow skip redirects" 303 (status skipped);
721
+
Added:
Alcotest.(check int)
722
+
Added:
"no feedback report was stored" 0
723
+
Added:
(List.length
724
+
Added:
(Lwt_main.run (Memory_repo.feedback repo trainee.Trainee.id)))
725
+
Added:
);
726
+
Added:
( "back keeps an earlier answer, which still persists on save",
727
+
Added:
`Quick,
728
+
Added:
fun () ->
729
+
Added:
let c, repo = client_with_repo () in
730
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
731
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
732
+
Added:
let _ = get c "/logbook?feedback=start" in
733
+
Added:
let step_action step action extra =
734
+
Added:
let token = Option.get (csrf_token (body (get c "/logbook"))) in
735
+
Added:
post c "/feedback"
736
+
Added:
(("dream.csrf", token)
737
+
Added:
:: ("step", string_of_int step)
738
+
Added:
:: ("action", action) :: extra)
739
+
Added:
in
740
+
Added:
(* Answer sleep (5), move to appetite, go back, then skip forward to
741
+
Added:
notes and save. The pre-back sleep answer must survive. *)
742
+
Added:
let _ = step_action 0 "next" [ ("choice", "5") ] in
743
+
Added:
let back = step_action 1 "back" [] in
744
+
Added:
Alcotest.(check int) "back redirects" 303 (status back);
745
+
Added:
let _ = step_action 0 "skip" [] in
746
+
Added:
let _ = step_action 1 "skip" [] in
747
+
Added:
let _ = step_action 2 "skip" [] in
748
+
Added:
let _ = step_action 3 "skip" [] in
749
+
Added:
let _ = step_action 4 "skip" [] in
750
+
Added:
let _ = step_action 5 "save" [] in
751
+
Added:
let reports =
752
+
Added:
Lwt_main.run (Memory_repo.feedback repo trainee.Trainee.id)
753
+
Added:
in
754
+
Added:
Alcotest.(check int) "one report stored" 1 (List.length reports);
755
+
Added:
let signals = Evidence.Feedback.signals (List.hd reports) in
756
+
Added:
Alcotest.(check bool)
757
+
Added:
"the pre-back sleep answer persists" true
758
+
Added:
(List.exists
759
+
Added:
(function
760
+
Added:
| Evidence.Feedback.Sleep Evidence.Feedback.Very_good -> true
761
+
Added:
| _ -> false)
762
+
Added:
signals) );
763
+
Added:
( "closing the feedback modal discards the flow",
764
+
Added:
`Quick,
765
+
Added:
fun () ->
766
+
Added:
let c, repo = client_with_repo () in
767
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
768
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
769
+
Added:
let start = body (get c "/logbook?feedback=start") in
770
+
Added:
let token = Option.get (csrf_token start) in
771
+
Added:
let _ =
772
+
Added:
post c "/feedback"
773
+
Added:
[
774
+
Added:
("dream.csrf", token);
775
+
Added:
("step", "0");
776
+
Added:
("action", "next");
777
+
Added:
("choice", "5");
778
+
Added:
]
779
+
Added:
in
780
+
Added:
let appetite = body (get c "/logbook") in
781
+
Added:
let token = Option.get (csrf_token appetite) in
782
+
Added:
let closed = post c "/feedback/cancel" [ ("dream.csrf", token) ] in
783
+
Added:
Alcotest.(check int) "close redirects" 303 (status closed);
784
+
Added:
(* A discarded flow stores nothing. *)
785
+
Added:
Alcotest.(check int)
786
+
Added:
"no feedback report stored" 0
787
+
Added:
(List.length
788
+
Added:
(Lwt_main.run (Memory_repo.feedback repo trainee.Trainee.id)))
789
+
Added:
);
790
+
Added:
( "finishing a workout redirects to the logbook feedback prompt",
791
+
Added:
`Quick,
792
+
Added:
fun () ->
793
+
Added:
let c, repo = client_with_repo () in
794
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
795
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
796
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
797
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
798
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
799
+
Added:
let _ =
800
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
801
+
Added:
in
802
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
803
+
Added:
let finished = post c "/workout/finish" [ ("dream.csrf", token) ] in
804
+
Added:
Alcotest.(check int) "finish redirects" 303 (status finished);
805
+
Added:
Alcotest.(check bool)
806
+
Added:
"redirects to the logbook with a feedback prompt" true
807
+
Added:
(List.mem "/logbook?prompt=feedback"
808
+
Added:
(Dream.headers finished "Location"));
809
+
Added:
(* The workout is now in history and no longer in progress. *)
810
+
Added:
Alcotest.(check int)
811
+
Added:
"one workout in history" 1
812
+
Added:
(List.length
813
+
Added:
(Lwt_main.run (Memory_repo.history repo trainee.Trainee.id)));
814
+
Added:
Alcotest.(check bool)
815
+
Added:
"nothing in progress" true
816
+
Added:
(Option.is_none
817
+
Added:
(Lwt_main.run
818
+
Added:
(Memory_repo.in_progress repo trainee.Trainee.id))) );
819
+
Added:
( "beginning before recovery is refused without an override",
820
+
Added:
`Quick,
821
+
Added:
fun () ->
822
+
Added:
let c, repo = client_with_repo () in
823
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
824
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
825
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
826
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
827
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
828
+
Added:
let _ =
829
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
830
+
Added:
in
831
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
832
+
Added:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
833
+
Added:
(* Recovery has not elapsed. Beginning the next workout without an
834
+
Added:
override is bounced back to Home and starts nothing. *)
835
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
836
+
Added:
let refused =
837
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
838
+
Added:
in
839
+
Added:
Alcotest.(check int) "early begin bounced" 303 (status refused);
840
+
Added:
Alcotest.(check bool)
841
+
Added:
"returns Home, not the workout" true
842
+
Added:
(List.mem "/" (Dream.headers refused "Location"));
843
+
Added:
Alcotest.(check bool)
844
+
Added:
"no new workout in progress" true
845
+
Added:
(Option.is_none
846
+
Added:
(Lwt_main.run
847
+
Added:
(Memory_repo.in_progress repo trainee.Trainee.id))) );
848
+
Added:
( "overriding recovery begins the next workout on an override basis",
849
+
Added:
`Quick,
850
+
Added:
fun () ->
851
+
Added:
let c, repo = client_with_repo () in
852
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
853
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
854
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
855
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
856
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
857
+
Added:
let _ =
858
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
859
+
Added:
in
860
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
861
+
Added:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
862
+
Added:
(* Recovery has not elapsed, so the next workout is begun under an
863
+
Added:
explicit override. The deviation is recorded on the workout's
864
+
Added:
clearance basis, never silent. *)
865
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
866
+
Added:
let began =
867
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "true") ]
868
+
Added:
in
869
+
Added:
Alcotest.(check int) "override begin succeeds" 303 (status began);
870
+
Added:
let basis =
871
+
Added:
match
872
+
Added:
Lwt_main.run (Memory_repo.in_progress repo trainee.Trainee.id)
873
+
Added:
with
874
+
Added:
| Some w -> Recovery.basis (Evidence.Workout.clearance w)
875
+
Added:
| None -> Alcotest.failf "expected a workout in progress"
876
+
Added:
in
877
+
Added:
Alcotest.(check bool)
878
+
Added:
"the workout carries an override basis" true
879
+
Added:
(match basis with Recovery.Overridden _ -> true | _ -> false) );
880
+
Added:
] );
881
+
Added:
( "web.app_feedback",
882
+
Added:
[
883
+
Added:
( "the app feedback screen and its tabs are served",
884
+
Added:
`Quick,
885
+
Added:
fun () ->
886
+
Added:
let c = client () in
887
+
Added:
let _ = sign_in_new c in
888
+
Added:
Alcotest.(check int)
889
+
Added:
"write tab served" 200
890
+
Added:
(status (get c "/app-feedback"));
891
+
Added:
Alcotest.(check int)
892
+
Added:
"submitted tab served" 200
893
+
Added:
(status (get c "/app-feedback?tab=submitted")) );
894
+
Added:
( "blank app feedback is refused; a real message is stored",
895
+
Added:
`Quick,
896
+
Added:
fun () ->
897
+
Added:
let c, repo = client_with_repo () in
898
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
899
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
900
+
Added:
let stored_count () =
901
+
Added:
List.length
902
+
Added:
(Lwt_main.run
903
+
Added:
(Memory_repo.app_feedback repo ~viewer:trainee.Trainee.id))
904
+
Added:
in
905
+
Added:
let token =
906
+
Added:
Option.get (csrf_token (body (get c "/app-feedback")))
907
+
Added:
in
908
+
Added:
let blank =
909
+
Added:
post c "/app-feedback/submit"
910
+
Added:
[ ("dream.csrf", token); ("message", " ") ]
911
+
Added:
in
912
+
Added:
Alcotest.(check int) "blank message redirects" 303 (status blank);
913
+
Added:
Alcotest.(check bool)
914
+
Added:
"redirects to the write tab" true
915
+
Added:
(List.mem "/app-feedback?tab=write"
916
+
Added:
(Dream.headers blank "Location"));
917
+
Added:
(* Blank input stored nothing. *)
918
+
Added:
Alcotest.(check int)
919
+
Added:
"no report stored for blank input" 0 (stored_count ());
920
+
Added:
let token =
921
+
Added:
Option.get (csrf_token (body (get c "/app-feedback")))
922
+
Added:
in
923
+
Added:
let submitted =
924
+
Added:
post c "/app-feedback/submit"
925
+
Added:
[
926
+
Added:
("dream.csrf", token);
927
+
Added:
("message", "The app is clear.\nThank you!");
928
+
Added:
]
929
+
Added:
in
930
+
Added:
Alcotest.(check int) "message is accepted" 303 (status submitted);
931
+
Added:
Alcotest.(check bool)
932
+
Added:
"redirects to submitted feedback" true
933
+
Added:
(List.mem "/app-feedback?tab=submitted"
934
+
Added:
(Dream.headers submitted "Location"));
935
+
Added:
(* The report now exists with the exact message. *)
936
+
Added:
let reports =
937
+
Added:
Lwt_main.run
938
+
Added:
(Memory_repo.app_feedback repo ~viewer:trainee.Trainee.id)
939
+
Added:
in
940
+
Added:
Alcotest.(check int) "one report stored" 1 (List.length reports);
941
+
Added:
Alcotest.(check string)
942
+
Added:
"stores the exact message" "The app is clear.\nThank you!"
943
+
Added:
(List.hd reports).Repository.message );
944
+
Added:
( "feedback records its author and accepts another trainee's vote",
945
+
Added:
`Quick,
946
+
Added:
fun () ->
947
+
Added:
let alice = client () in
948
+
Added:
let _ = register alice ~username:"alice" ~password:"heavyduty1" in
949
+
Added:
let bob = { alice with jar = [] } in
950
+
Added:
let _ = register bob ~username:"bobby" ~password:"heavyduty1" in
951
+
Added:
let submit client message =
952
+
Added:
let token =
953
+
Added:
Option.get (csrf_token (body (get client "/app-feedback")))
954
+
Added:
in
955
+
Added:
post client "/app-feedback/submit"
956
+
Added:
[ ("dream.csrf", token); ("message", message) ]
957
+
Added:
in
958
+
Added:
Alcotest.(check int)
959
+
Added:
"alice feedback accepted" 303
960
+
Added:
(status (submit alice "Alice report"));
961
+
Added:
Alcotest.(check int)
962
+
Added:
"bob feedback accepted" 303
963
+
Added:
(status (submit bob "Bob report"));
964
+
Added:
(* Bob's report is t2:1 — the vote control on Alice's view posts
965
+
Added:
there. Alice upvotes it. *)
966
+
Added:
let list_page = body (get alice "/app-feedback?tab=submitted") in
967
+
Added:
let token = Option.get (csrf_token list_page) in
968
+
Added:
let voted =
969
+
Added:
post alice "/app-feedback/t2:1/upvote" [ ("dream.csrf", token) ]
970
+
Added:
in
971
+
Added:
Alcotest.(check int) "upvote redirects" 303 (status voted) );
972
+
Added:
( "an owner can edit and remove their feedback",
973
+
Added:
`Quick,
974
+
Added:
fun () ->
975
+
Added:
let c, repo = client_with_repo () in
976
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
977
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
978
+
Added:
let token =
979
+
Added:
Option.get (csrf_token (body (get c "/app-feedback")))
980
+
Added:
in
981
+
Added:
let _ =
982
+
Added:
post c "/app-feedback/submit"
983
+
Added:
[ ("dream.csrf", token); ("message", "First report") ]
984
+
Added:
in
985
+
Added:
let feedback_id () =
986
+
Added:
(List.hd
987
+
Added:
(Lwt_main.run
988
+
Added:
(Memory_repo.app_feedback repo ~viewer:trainee.Trainee.id)))
989
+
Added:
.Repository.feedback_id |> Repository.app_feedback_id_to_string
990
+
Added:
in
991
+
Added:
let id = feedback_id () in
992
+
Added:
(* A blank edit is refused and changes nothing. *)
993
+
Added:
let token =
994
+
Added:
Option.get
995
+
Added:
(csrf_token (body (get c "/app-feedback?tab=submitted")))
996
+
Added:
in
997
+
Added:
let blank =
998
+
Added:
post c
999
+
Added:
("/app-feedback/" ^ id ^ "/edit")
1000
+
Added:
[ ("dream.csrf", token); ("message", " ") ]
1001
+
Added:
in
1002
+
Added:
Alcotest.(check int) "blank edit redirects" 303 (status blank);
1003
+
Added:
Alcotest.(check string)
1004
+
Added:
"message unchanged by blank edit" "First report"
1005
+
Added:
(List.hd
1006
+
Added:
(Lwt_main.run
1007
+
Added:
(Memory_repo.app_feedback repo ~viewer:trainee.Trainee.id)))
1008
+
Added:
.Repository.message;
1009
+
Added:
(* A real edit changes the stored message. *)
1010
+
Added:
let token =
1011
+
Added:
Option.get
1012
+
Added:
(csrf_token (body (get c "/app-feedback?tab=submitted")))
1013
+
Added:
in
1014
+
Added:
let edited =
1015
+
Added:
post c
1016
+
Added:
("/app-feedback/" ^ id ^ "/edit")
1017
+
Added:
[ ("dream.csrf", token); ("message", "Edited report") ]
1018
+
Added:
in
1019
+
Added:
Alcotest.(check int) "edit redirects" 303 (status edited);
1020
+
Added:
Alcotest.(check string)
1021
+
Added:
"message updated by edit" "Edited report"
1022
+
Added:
(List.hd
1023
+
Added:
(Lwt_main.run
1024
+
Added:
(Memory_repo.app_feedback repo ~viewer:trainee.Trainee.id)))
1025
+
Added:
.Repository.message;
1026
+
Added:
(* Removal deletes the report. *)
1027
+
Added:
let token =
1028
+
Added:
Option.get
1029
+
Added:
(csrf_token (body (get c "/app-feedback?tab=submitted")))
1030
+
Added:
in
1031
+
Added:
let removed =
1032
+
Added:
post c
1033
+
Added:
("/app-feedback/" ^ id ^ "/remove")
1034
+
Added:
[ ("dream.csrf", token) ]
1035
+
Added:
in
1036
+
Added:
Alcotest.(check int) "remove redirects" 303 (status removed);
1037
+
Added:
Alcotest.(check int)
1038
+
Added:
"report removed" 0
1039
+
Added:
(List.length
1040
+
Added:
(Lwt_main.run
1041
+
Added:
(Memory_repo.app_feedback repo ~viewer:trainee.Trainee.id)))
1042
+
Added:
);
1043
+
Added:
( "another trainee cannot edit or remove app feedback",
1044
+
Added:
`Quick,
1045
+
Added:
fun () ->
1046
+
Added:
let alice, repo = client_with_repo () in
1047
+
Added:
let _ = register alice ~username:"alice" ~password:"heavyduty1" in
1048
+
Added:
let bob = { alice with jar = [] } in
1049
+
Added:
let _ = register bob ~username:"bobby" ~password:"heavyduty1" in
1050
+
Added:
let bob_trainee = the_trainee repo ~username:"bobby" in
1051
+
Added:
(* Bob owns t2:1. Alice attempts to edit and remove it. Both are
1052
+
Added:
refused at the ownership check, so the report and its message
1053
+
Added:
survive. *)
1054
+
Added:
let token =
1055
+
Added:
Option.get (csrf_token (body (get bob "/app-feedback")))
1056
+
Added:
in
1057
+
Added:
let _ =
1058
+
Added:
post bob "/app-feedback/submit"
1059
+
Added:
[ ("dream.csrf", token); ("message", "Bob owns this") ]
1060
+
Added:
in
1061
+
Added:
let stored () =
1062
+
Added:
Lwt_main.run
1063
+
Added:
(Memory_repo.app_feedback repo ~viewer:bob_trainee.Trainee.id)
1064
+
Added:
in
1065
+
Added:
Alcotest.(check int)
1066
+
Added:
"one report before cross-user attempts" 1
1067
+
Added:
(List.length (stored ()));
1068
+
Added:
let token =
1069
+
Added:
Option.get
1070
+
Added:
(csrf_token (body (get alice "/app-feedback?tab=submitted")))
1071
+
Added:
in
1072
+
Added:
let edited =
1073
+
Added:
post alice "/app-feedback/t2:1/edit"
1074
+
Added:
[ ("dream.csrf", token); ("message", "Taken over") ]
1075
+
Added:
in
1076
+
Added:
Alcotest.(check int) "cross-user edit redirects" 303 (status edited);
1077
+
Added:
let token =
1078
+
Added:
Option.get
1079
+
Added:
(csrf_token (body (get alice "/app-feedback?tab=submitted")))
1080
+
Added:
in
1081
+
Added:
let removed =
1082
+
Added:
post alice "/app-feedback/t2:1/remove" [ ("dream.csrf", token) ]
1083
+
Added:
in
1084
+
Added:
Alcotest.(check int)
1085
+
Added:
"cross-user remove redirects" 303 (status removed);
1086
+
Added:
(* The report still exists with its original message: the cross-user
1087
+
Added:
attempts changed nothing. *)
1088
+
Added:
let after = stored () in
1089
+
Added:
Alcotest.(check int)
1090
+
Added:
"report survives cross-user attempts" 1 (List.length after);
1091
+
Added:
Alcotest.(check string)
1092
+
Added:
"message is unchanged" "Bob owns this"
1093
+
Added:
(List.hd after).Repository.message );
1094
+
Added:
] );
1095
+
Added:
( "web.profile",
1096
+
Added:
[
1097
+
Added:
( "the profile page is served for a signed-in trainee",
1098
+
Added:
`Quick,
1099
+
Added:
fun () ->
1100
+
Added:
let c = client () in
1101
+
Added:
let _ = sign_in_new c in
1102
+
Added:
Alcotest.(check int)
1103
+
Added:
"profile served" 200
1104
+
Added:
(status (get c "/profile")) );
1105
+
Added:
( "changing the username updates the stored account",
1106
+
Added:
`Quick,
1107
+
Added:
fun () ->
1108
+
Added:
let c, repo = client_with_repo () in
1109
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1110
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1111
+
Added:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1112
+
Added:
let changed =
1113
+
Added:
post c "/profile/username"
1114
+
Added:
[ ("dream.csrf", token); ("username", "athlete") ]
1115
+
Added:
in
1116
+
Added:
Alcotest.(check int)
1117
+
Added:
"redirects after the change" 303 (status changed);
1118
+
Added:
Alcotest.(check bool)
1119
+
Added:
"redirects back to the profile with a notice" true
1120
+
Added:
(List.mem "/profile?changed=username"
1121
+
Added:
(Dream.headers changed "Location"));
1122
+
Added:
(* The account now answers to the new name, and no longer to the
1123
+
Added:
old one. *)
1124
+
Added:
let renamed =
1125
+
Added:
Lwt_main.run (Memory_repo.find_trainee repo trainee.Trainee.id)
1126
+
Added:
in
1127
+
Added:
Alcotest.(check (option string))
1128
+
Added:
"stored under the new username" (Some "athlete")
1129
+
Added:
(Option.map
1130
+
Added:
(fun t -> Trainee.username_to_string t.Trainee.username)
1131
+
Added:
renamed);
1132
+
Added:
Alcotest.(check bool)
1133
+
Added:
"old name no longer resolves" true
1134
+
Added:
(Option.is_none
1135
+
Added:
(Lwt_main.run
1136
+
Added:
(match Trainee.username "lifter" with
1137
+
Added:
| Ok u -> Memory_repo.find_trainee_by_username repo u
1138
+
Added:
| Error _ -> Lwt.return None))) );
1139
+
Added:
( "a taken username is refused and the account keeps its name",
1140
+
Added:
`Quick,
1141
+
Added:
fun () ->
1142
+
Added:
let c, repo = client_with_repo () in
1143
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1144
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1145
+
Added:
(* Register a second account in the same store. *)
1146
+
Added:
let other = { c with jar = [] } in
1147
+
Added:
let _ = register other ~username:"taken" ~password:"heavyduty1" in
1148
+
Added:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1149
+
Added:
let refused =
1150
+
Added:
post c "/profile/username"
1151
+
Added:
[ ("dream.csrf", token); ("username", "taken") ]
1152
+
Added:
in
1153
+
Added:
Alcotest.(check int)
1154
+
Added:
"renders an invalid response" 400 (status refused);
1155
+
Added:
(* The account still holds its original name. *)
1156
+
Added:
Alcotest.(check (option string))
1157
+
Added:
"name unchanged after refusal" (Some "lifter")
1158
+
Added:
(Option.map
1159
+
Added:
(fun t -> Trainee.username_to_string t.Trainee.username)
1160
+
Added:
(Lwt_main.run
1161
+
Added:
(Memory_repo.find_trainee repo trainee.Trainee.id))) );
1162
+
Added:
( "changing the password requires the current one",
1163
+
Added:
`Quick,
1164
+
Added:
fun () ->
1165
+
Added:
let c = client () in
1166
+
Added:
let _ = sign_in_new c in
1167
+
Added:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1168
+
Added:
let refused =
1169
+
Added:
post c "/profile/password"
1170
+
Added:
[
1171
+
Added:
("dream.csrf", token);
1172
+
Added:
("current", "wrongpass");
1173
+
Added:
("next", "newsecret1");
1174
+
Added:
]
1175
+
Added:
in
1176
+
Added:
Alcotest.(check int)
1177
+
Added:
"renders an invalid response" 400 (status refused);
1178
+
Added:
(* Now change it with the correct current password. *)
1179
+
Added:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1180
+
Added:
let changed =
1181
+
Added:
post c "/profile/password"
1182
+
Added:
[
1183
+
Added:
("dream.csrf", token);
1184
+
Added:
("current", "heavyduty1");
1185
+
Added:
("next", "newsecret1");
1186
+
Added:
]
1187
+
Added:
in
1188
+
Added:
Alcotest.(check int)
1189
+
Added:
"redirects after the change" 303 (status changed);
1190
+
Added:
Alcotest.(check bool)
1191
+
Added:
"redirects back to the profile with a notice" true
1192
+
Added:
(List.mem "/profile?changed=password"
1193
+
Added:
(Dream.headers changed "Location"));
1194
+
Added:
(* The new password now authenticates and the old one no longer
1195
+
Added:
does. Sign out, then prove the change through the sign-in flow. *)
1196
+
Added:
let token = Option.get (csrf_token (body (get c "/profile"))) in
1197
+
Added:
let _ = post c "/logout" [ ("dream.csrf", token) ] in
1198
+
Added:
let old_pw = sign_in c ~username:"lifter" ~password:"heavyduty1" in
1199
+
Added:
Alcotest.(check int)
1200
+
Added:
"the old password no longer works" 401 (status old_pw);
1201
+
Added:
let new_pw = sign_in c ~username:"lifter" ~password:"newsecret1" in
1202
+
Added:
Alcotest.(check int)
1203
+
Added:
"the new password authenticates" 303 (status new_pw) );
1204
+
Added:
] );
1205
+
Added:
( "web.errors",
1206
+
Added:
[
1207
+
Added:
( "an unknown path responds 404",
1208
+
Added:
`Quick,
1209
+
Added:
fun () ->
1210
+
Added:
let c = client () in
1211
+
Added:
let _ = sign_in_new c in
1212
+
Added:
Alcotest.(check int)
1213
+
Added:
"responds 404" 404
1214
+
Added:
(status (get c "/no-such-page")) );
1215
+
Added:
( "the 404 response never depends on a session",
1216
+
Added:
`Quick,
1217
+
Added:
fun () ->
1218
+
Added:
(* No sign-in: an unauthenticated visitor still gets a 404, not a
1219
+
Added:
redirect to sign-in. The branded page is not routed through the
1220
+
Added:
session guard. *)
1221
+
Added:
let c = client () in
1222
+
Added:
Alcotest.(check int)
1223
+
Added:
"unauthenticated 404" 404
1224
+
Added:
(status (get c "/deep/unknown/path")) );
1225
+
Added:
] );
1226
+
Added:
( "web.sign_in",
1227
+
Added:
[
1228
+
Added:
( "a returning trainee signs in with their credentials",
1229
+
Added:
`Quick,
1230
+
Added:
fun () ->
1231
+
Added:
(* Register, then sign out, then sign back in through the credential
1232
+
Added:
form. This exercises [POST /login], which registration's
1233
+
Added:
auto-sign-in never touches. *)
1234
+
Added:
let c = client () in
1235
+
Added:
let _ = sign_in_new c in
1236
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1237
+
Added:
let _ = post c "/logout" [ ("dream.csrf", token) ] in
1238
+
Added:
Alcotest.(check int)
1239
+
Added:
"signed out, overview redirects" 303
1240
+
Added:
(status (get c "/"));
1241
+
Added:
let signed_in =
1242
+
Added:
sign_in c ~username:"lifter" ~password:"heavyduty1"
1243
+
Added:
in
1244
+
Added:
Alcotest.(check int) "sign-in redirects" 303 (status signed_in);
1245
+
Added:
Alcotest.(check bool)
1246
+
Added:
"sign-in lands on Home" true
1247
+
Added:
(List.mem "/" (Dream.headers signed_in "Location"));
1248
+
Added:
Alcotest.(check int)
1249
+
Added:
"authenticated overview" 200
1250
+
Added:
(status (get c "/")) );
1251
+
Added:
( "a wrong password is refused and establishes no session",
1252
+
Added:
`Quick,
1253
+
Added:
fun () ->
1254
+
Added:
let c = client () in
1255
+
Added:
let _ = sign_in_new c in
1256
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1257
+
Added:
let _ = post c "/logout" [ ("dream.csrf", token) ] in
1258
+
Added:
let refused =
1259
+
Added:
sign_in c ~username:"lifter" ~password:"not-the-password"
1260
+
Added:
in
1261
+
Added:
Alcotest.(check int) "rejected" 401 (status refused);
1262
+
Added:
(* The rejected attempt establishes no session. *)
1263
+
Added:
Alcotest.(check int)
1264
+
Added:
"still unauthenticated" 303
1265
+
Added:
(status (get c "/")) );
1266
+
Added:
( "an unknown username is refused like a wrong password",
1267
+
Added:
`Quick,
1268
+
Added:
fun () ->
1269
+
Added:
let c = client () in
1270
+
Added:
let refused = sign_in c ~username:"nobody" ~password:"heavyduty1" in
1271
+
Added:
Alcotest.(check int) "rejected" 401 (status refused);
1272
+
Added:
Alcotest.(check int)
1273
+
Added:
"no session established" 303
1274
+
Added:
(status (get c "/")) );
1275
+
Added:
( "an already-authenticated visit to the sign-in page redirects Home",
1276
+
Added:
`Quick,
1277
+
Added:
fun () ->
1278
+
Added:
let c = client () in
1279
+
Added:
let _ = sign_in_new c in
1280
+
Added:
let response = get c "/login" in
1281
+
Added:
Alcotest.(check int) "redirects" 303 (status response);
1282
+
Added:
Alcotest.(check bool)
1283
+
Added:
"sends an authenticated visitor Home" true
1284
+
Added:
(List.mem "/" (Dream.headers response "Location")) );
1285
+
Added:
( "an already-authenticated visit to the register page redirects Home",
1286
+
Added:
`Quick,
1287
+
Added:
fun () ->
1288
+
Added:
let c = client () in
1289
+
Added:
let _ = sign_in_new c in
1290
+
Added:
let response = get c "/register" in
1291
+
Added:
Alcotest.(check int) "redirects" 303 (status response);
1292
+
Added:
Alcotest.(check bool)
1293
+
Added:
"sends an authenticated visitor Home" true
1294
+
Added:
(List.mem "/" (Dream.headers response "Location")) );
1295
+
Added:
] );
1296
+
Added:
( "web.routine_selection",
1297
+
Added:
[
1298
+
Added:
( "selecting an unknown routine is not found and selects nothing",
1299
+
Added:
`Quick,
1300
+
Added:
fun () ->
1301
+
Added:
let c, repo = client_with_repo () in
1302
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1303
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1304
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1305
+
Added:
let response =
1306
+
Added:
post c "/routines/no-such-routine/select"
1307
+
Added:
[ ("dream.csrf", token) ]
1308
+
Added:
in
1309
+
Added:
Alcotest.(check int) "responds 404" 404 (status response);
1310
+
Added:
(* A missing routine leaves the trainee with no active routine. *)
1311
+
Added:
Alcotest.(check bool)
1312
+
Added:
"no routine was selected" true
1313
+
Added:
(Option.is_none
1314
+
Added:
(Lwt_main.run
1315
+
Added:
(Memory_repo.active_routine repo trainee.Trainee.id))) );
1316
+
Added:
] );
1317
+
Added:
( "web.saved_record_add",
1318
+
Added:
[
1319
+
Added:
( "an outstanding slot of a saved workout can be recorded, not only \
1320
+
Added:
corrected",
1321
+
Added:
`Quick,
1322
+
Added:
fun () ->
1323
+
Added:
(* Finish a workout with a single slot recorded, so the saved record
1324
+
Added:
still has outstanding slots. Then record one of those through the
1325
+
Added:
plain add path [POST /logbook/:id/slots/:d], which the edit tests
1326
+
Added:
never exercise. *)
1327
+
Added:
let c, repo = client_with_repo () in
1328
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1329
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1330
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1331
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1332
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1333
+
Added:
let _ =
1334
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1335
+
Added:
in
1336
+
Added:
(* Record only slot 1, then finish. Slots 0, 2, 3 stay outstanding. *)
1337
+
Added:
let token =
1338
+
Added:
Option.get (csrf_token (body (get c "/workout?slot=1")))
1339
+
Added:
in
1340
+
Added:
let _ =
1341
+
Added:
post c "/workout/slots/1"
1342
+
Added:
[
1343
+
Added:
("dream.csrf", token);
1344
+
Added:
("load", "12");
1345
+
Added:
("reps", "8");
1346
+
Added:
("extension", "");
1347
+
Added:
]
1348
+
Added:
in
1349
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1350
+
Added:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
1351
+
Added:
let filled_of_w1 () =
1352
+
Added:
match
1353
+
Added:
Lwt_main.run
1354
+
Added:
(Memory_repo.find repo trainee.Trainee.id
1355
+
Added:
(Repository.workout_id "w1"))
1356
+
Added:
with
1357
+
Added:
| Some r -> Evidence.Workout.filled_slots r.Repository.workout
1358
+
Added:
| None -> Alcotest.failf "expected the saved workout w1"
1359
+
Added:
in
1360
+
Added:
Alcotest.(check int)
1361
+
Added:
"saved workout starts with one slot" 1 (filled_of_w1 ());
1362
+
Added:
(* Add slot 2 through the non-edit path. *)
1363
+
Added:
let token =
1364
+
Added:
Option.get (csrf_token (body (get c "/logbook/w1?slot=2")))
1365
+
Added:
in
1366
+
Added:
let added =
1367
+
Added:
post c "/logbook/w1/slots/2"
1368
+
Added:
[
1369
+
Added:
("dream.csrf", token);
1370
+
Added:
("load", "10");
1371
+
Added:
("reps", "9");
1372
+
Added:
("extension", "");
1373
+
Added:
]
1374
+
Added:
in
1375
+
Added:
Alcotest.(check int)
1376
+
Added:
"recorded a saved-workout slot" 303 (status added);
1377
+
Added:
Alcotest.(check int) "now two filled slots" 2 (filled_of_w1 ()) );
1378
+
Added:
( "recording an already-filled slot through the add path is refused",
1379
+
Added:
`Quick,
1380
+
Added:
fun () ->
1381
+
Added:
(* The add path lists only outstanding slots. A filled slot is not
1382
+
Added:
awaiting a record, so posting to it is a 404, not a silent second
1383
+
Added:
entry that would add volume. *)
1384
+
Added:
let c, repo = client_with_repo () in
1385
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1386
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1387
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1388
+
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1389
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1390
+
Added:
let _ =
1391
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1392
+
Added:
in
1393
+
Added:
let token =
1394
+
Added:
Option.get (csrf_token (body (get c "/workout?slot=1")))
1395
+
Added:
in
1396
+
Added:
let _ =
1397
+
Added:
post c "/workout/slots/1"
1398
+
Added:
[
1399
+
Added:
("dream.csrf", token);
1400
+
Added:
("load", "12");
1401
+
Added:
("reps", "8");
1402
+
Added:
("extension", "");
1403
+
Added:
]
1404
+
Added:
in
1405
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1406
+
Added:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
1407
+
Added:
(* Slot 1 is filled. Re-posting to its add path is refused. *)
1408
+
Added:
let token =
1409
+
Added:
Option.get (csrf_token (body (get c "/logbook/w1?slot=2")))
1410
+
Added:
in
1411
+
Added:
let refused =
1412
+
Added:
post c "/logbook/w1/slots/1"
1413
+
Added:
[
1414
+
Added:
("dream.csrf", token);
1415
+
Added:
("load", "20");
1416
+
Added:
("reps", "6");
1417
+
Added:
("extension", "");
1418
+
Added:
]
1419
+
Added:
in
1420
+
Added:
Alcotest.(check int)
1421
+
Added:
"filled slot is not awaiting" 404 (status refused);
1422
+
Added:
(* The refused post added no volume: still one filled slot. *)
1423
+
Added:
Alcotest.(check int)
1424
+
Added:
"still one filled slot" 1
1425
+
Added:
(match
1426
+
Added:
Lwt_main.run
1427
+
Added:
(Memory_repo.find repo trainee.Trainee.id
1428
+
Added:
(Repository.workout_id "w1"))
1429
+
Added:
with
1430
+
Added:
| Some r -> Evidence.Workout.filled_slots r.Repository.workout
1431
+
Added:
| None -> Alcotest.failf "expected the saved workout w1") );
1432
+
Added:
] );
1433
+
Added:
( "web.slot_bounds",
1434
+
Added:
[
1435
+
Added:
( "posting to a slot beyond the workout is not found",
1436
+
Added:
`Quick,
1437
+
Added:
fun () ->
1438
+
Added:
(* Day 1 has four slots (0..3). Slot 9 does not exist, so the log
1439
+
Added:
handler answers 404 rather than recording phantom volume. *)
1440
+
Added:
let c, repo = client_with_repo () in
1441
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1442
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1443
+
Added:
let _ = start_day_one c in
1444
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1445
+
Added:
let response =
1446
+
Added:
post c "/workout/slots/9"
1447
+
Added:
[
1448
+
Added:
("dream.csrf", token);
1449
+
Added:
("load", "10");
1450
+
Added:
("reps", "8");
1451
+
Added:
("extension", "");
1452
+
Added:
]
1453
+
Added:
in
1454
+
Added:
Alcotest.(check int)
1455
+
Added:
"out-of-range slot is not found" 404 (status response);
1456
+
Added:
(* No phantom volume was recorded. *)
1457
+
Added:
Alcotest.(check int)
1458
+
Added:
"nothing recorded" 0
1459
+
Added:
(match
1460
+
Added:
Lwt_main.run (Memory_repo.in_progress repo trainee.Trainee.id)
1461
+
Added:
with
1462
+
Added:
| Some w -> Evidence.Workout.filled_slots w
1463
+
Added:
| None -> Alcotest.failf "expected a workout in progress") );
1464
+
Added:
( "editing a slot beyond the workout is not found",
1465
+
Added:
`Quick,
1466
+
Added:
fun () ->
1467
+
Added:
let c = client () in
1468
+
Added:
let _ = sign_in_new c in
1469
+
Added:
let _ = start_day_one c in
1470
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1471
+
Added:
let response =
1472
+
Added:
post c "/workout/slots/9/edit"
1473
+
Added:
[
1474
+
Added:
("dream.csrf", token);
1475
+
Added:
("load", "10");
1476
+
Added:
("reps", "8");
1477
+
Added:
("extension", "");
1478
+
Added:
]
1479
+
Added:
in
1480
+
Added:
Alcotest.(check int)
1481
+
Added:
"out-of-range edit is not found" 404 (status response) );
1482
+
Added:
( "logging with no workout in progress is not found",
1483
+
Added:
`Quick,
1484
+
Added:
fun () ->
1485
+
Added:
let c = client () in
1486
+
Added:
let _ = sign_in_new c in
1487
+
Added:
(* No routine selected, no workout begun: the CSRF token comes from
1488
+
Added:
Home, but the slot route has no workout to record against. *)
1489
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1490
+
Added:
let response =
1491
+
Added:
post c "/workout/slots/0"
1492
+
Added:
[
1493
+
Added:
("dream.csrf", token);
1494
+
Added:
("load", "10");
1495
+
Added:
("reps", "8");
1496
+
Added:
("extension", "");
1497
+
Added:
]
1498
+
Added:
in
1499
+
Added:
Alcotest.(check int) "no workout is not found" 404 (status response)
1500
+
Added:
);
1501
+
Added:
] );
1502
+
Added:
( "web.duplicate_feedback",
1503
+
Added:
[
1504
+
Added:
( "a step out of sync with the session records nothing",
1505
+
Added:
`Quick,
1506
+
Added:
fun () ->
1507
+
Added:
(* The flow advances one factor at a time and stores its position in
1508
+
Added:
the session. A post whose [step] disagrees with that position —
1509
+
Added:
a stale form, a double submit, or a crafted body — is refused as
1510
+
Added:
an invalid form rather than recording a factor twice. This is the
1511
+
Added:
web tier's guard against a duplicate signal ever reaching the
1512
+
Added:
domain. *)
1513
+
Added:
let c, repo = client_with_repo () in
1514
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1515
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1516
+
Added:
let start = body (get c "/logbook?feedback=start") in
1517
+
Added:
let token = Option.get (csrf_token start) in
1518
+
Added:
(* The session is at step 0; post step 3 instead. *)
1519
+
Added:
let mismatched =
1520
+
Added:
post c "/feedback"
1521
+
Added:
[
1522
+
Added:
("dream.csrf", token);
1523
+
Added:
("step", "3");
1524
+
Added:
("action", "next");
1525
+
Added:
("choice", "1");
1526
+
Added:
]
1527
+
Added:
in
1528
+
Added:
Alcotest.(check int)
1529
+
Added:
"mismatched step redirects" 303 (status mismatched);
1530
+
Added:
(* The mismatched post advanced nothing and stored no report. *)
1531
+
Added:
Alcotest.(check int)
1532
+
Added:
"no feedback stored" 0
1533
+
Added:
(List.length
1534
+
Added:
(Lwt_main.run (Memory_repo.feedback repo trainee.Trainee.id)))
1535
+
Added:
);
1536
+
Added:
( "answering every factor stores exactly one signal per category",
1537
+
Added:
`Quick,
1538
+
Added:
fun () ->
1539
+
Added:
(* The flow admits one answer per factor, so the stored report holds
1540
+
Added:
one signal per category and never a duplicate. Walking all five
1541
+
Added:
factors and saving proves the whole set persists without a
1542
+
Added:
duplicate-signal rejection. *)
1543
+
Added:
let c, repo = client_with_repo () in
1544
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1545
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1546
+
Added:
let _ = get c "/logbook?feedback=start" in
1547
+
Added:
let rec advance step =
1548
+
Added:
if step < 5 then begin
1549
+
Added:
let token = Option.get (csrf_token (body (get c "/logbook"))) in
1550
+
Added:
let _ =
1551
+
Added:
post c "/feedback"
1552
+
Added:
[
1553
+
Added:
("dream.csrf", token);
1554
+
Added:
("step", string_of_int step);
1555
+
Added:
("action", "next");
1556
+
Added:
("choice", "1");
1557
+
Added:
]
1558
+
Added:
in
1559
+
Added:
advance (step + 1)
1560
+
Added:
end
1561
+
Added:
in
1562
+
Added:
advance 0;
1563
+
Added:
let token = Option.get (csrf_token (body (get c "/logbook"))) in
1564
+
Added:
let saved =
1565
+
Added:
post c "/feedback"
1566
+
Added:
[ ("dream.csrf", token); ("step", "5"); ("action", "save") ]
1567
+
Added:
in
1568
+
Added:
Alcotest.(check int) "final save redirects" 303 (status saved);
1569
+
Added:
(* Exactly one report, with one signal per distinct factor. *)
1570
+
Added:
let reports =
1571
+
Added:
Lwt_main.run (Memory_repo.feedback repo trainee.Trainee.id)
1572
+
Added:
in
1573
+
Added:
Alcotest.(check int) "one report stored" 1 (List.length reports);
1574
+
Added:
let signals = Evidence.Feedback.signals (List.hd reports) in
1575
+
Added:
let count p = List.length (List.filter p signals) in
1576
+
Added:
let open Evidence.Feedback in
1577
+
Added:
Alcotest.(check int)
1578
+
Added:
"one sleep signal" 1
1579
+
Added:
(count (function Sleep _ -> true | _ -> false));
1580
+
Added:
Alcotest.(check int)
1581
+
Added:
"one appetite signal" 1
1582
+
Added:
(count (function Appetite _ -> true | _ -> false));
1583
+
Added:
Alcotest.(check int)
1584
+
Added:
"one readiness signal" 1
1585
+
Added:
(count (function Readiness _ -> true | _ -> false));
1586
+
Added:
Alcotest.(check int)
1587
+
Added:
"one motivation signal" 1
1588
+
Added:
(count (function Motivation _ -> true | _ -> false));
1589
+
Added:
Alcotest.(check int)
1590
+
Added:
"one difficulty signal" 1
1591
+
Added:
(count (function Difficulty _ -> true | _ -> false)) );
1592
+
Added:
] );
1593
+
Added:
( "web.lifecycle",
1594
+
Added:
[
1595
+
Added:
( "a full cycle: begin Day 1, record every slot, finish, and see it \
1596
+
Added:
saved complete",
1597
+
Added:
`Quick,
1598
+
Added:
fun () ->
1599
+
Added:
(* A deep end-to-end pass over the primary flow with a driven clock.
1600
+
Added:
Begin Day 1, record all four slots, finish, and confirm the
1601
+
Added:
logbook persists a complete Day 1 with four stimuli. The clocked
1602
+
Added:
client and repo share a store, so the flow's domain effect is
1603
+
Added:
read straight from history. *)
1604
+
Added:
let c, now, repo = clocked_client () in
1605
+
Added:
now := day 10;
1606
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1607
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1608
+
Added:
let _ = start_day_one c in
1609
+
Added:
let in_progress_filled () =
1610
+
Added:
match
1611
+
Added:
Lwt_main.run (Memory_repo.in_progress repo trainee.Trainee.id)
1612
+
Added:
with
1613
+
Added:
| Some w -> Evidence.Workout.filled_slots w
1614
+
Added:
| None -> Alcotest.failf "expected a workout in progress"
1615
+
Added:
in
1616
+
Added:
Alcotest.(check int)
1617
+
Added:
"opens with no slots recorded" 0 (in_progress_filled ());
1618
+
Added:
record_all_day_one_slots c;
1619
+
Added:
Alcotest.(check int)
1620
+
Added:
"all four slots recorded" 4 (in_progress_filled ());
1621
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1622
+
Added:
let finished = post c "/workout/finish" [ ("dream.csrf", token) ] in
1623
+
Added:
Alcotest.(check int) "finish redirects" 303 (status finished);
1624
+
Added:
Alcotest.(check bool)
1625
+
Added:
"sends the trainee to the logbook feedback prompt" true
1626
+
Added:
(List.mem "/logbook?prompt=feedback"
1627
+
Added:
(Dream.headers finished "Location"));
1628
+
Added:
(* History holds one complete Day 1 with four stimuli. *)
1629
+
Added:
let history =
1630
+
Added:
Lwt_main.run (Memory_repo.history repo trainee.Trainee.id)
1631
+
Added:
in
1632
+
Added:
Alcotest.(check int)
1633
+
Added:
"one workout in history" 1 (List.length history);
1634
+
Added:
let saved = (List.hd history).Repository.workout in
1635
+
Added:
Alcotest.(check string)
1636
+
Added:
"the saved workout is Day 1" "Day 1"
1637
+
Added:
(Prescription.Workout.name (Evidence.Workout.prescription saved));
1638
+
Added:
Alcotest.(check int)
1639
+
Added:
"records four stimuli" 4
1640
+
Added:
(List.length (Evidence.Workout.stimuli saved));
1641
+
Added:
Alcotest.(check bool)
1642
+
Added:
"the saved workout is complete" true
1643
+
Added:
(Evidence.Workout.completeness saved = Evidence.Workout.Complete);
1644
+
Added:
(* The saved workout is durable and reachable at its own URL. *)
1645
+
Added:
Alcotest.(check int)
1646
+
Added:
"the saved workout is reachable" 200
1647
+
Added:
(status (get c "/logbook/w1")) );
1648
+
Added:
( "the recovery gate blocks the next workout until enough rest, then \
1649
+
Added:
Day 2 begins",
1650
+
Added:
`Quick,
1651
+
Added:
fun () ->
1652
+
Added:
(* The deepest flow: after finishing Day 1, the next workout is
1653
+
Added:
gated until 48 hours of recovery pass. The web tier drives the
1654
+
Added:
clock, so this proves the gate over HTTP, not just in the
1655
+
Added:
service. Beginning too early is refused; resting past the
1656
+
Added:
interval clears the gate and advances the cycle to Day 2. *)
1657
+
Added:
let c, now, repo = clocked_client () in
1658
+
Added:
now := day 10;
1659
+
Added:
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
1660
+
Added:
let trainee = the_trainee repo ~username:"lifter" in
1661
+
Added:
let _ = start_day_one c in
1662
+
Added:
record_all_day_one_slots c;
1663
+
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1664
+
Added:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
1665
+
Added:
(* One hour later: recovery is far from complete. Beginning without
1666
+
Added:
an override is refused and starts nothing. *)
1667
+
Added:
now := day 10 + 3600;
1668
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1669
+
Added:
let refused =
1670
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1671
+
Added:
in
1672
+
Added:
Alcotest.(check int)
1673
+
Added:
"an early begin is bounced back" 303 (status refused);
1674
+
Added:
Alcotest.(check bool)
1675
+
Added:
"the early begin returns Home, not the workout" true
1676
+
Added:
(List.mem "/" (Dream.headers refused "Location"));
1677
+
Added:
Alcotest.(check bool)
1678
+
Added:
"no workout was started" true
1679
+
Added:
(Option.is_none
1680
+
Added:
(Lwt_main.run
1681
+
Added:
(Memory_repo.in_progress repo trainee.Trainee.id)));
1682
+
Added:
(* Rest two full days: past the 48-hour training interval. The gate
1683
+
Added:
clears and the next begin succeeds. *)
1684
+
Added:
now := day 13;
1685
+
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1686
+
Added:
let began =
1687
+
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1688
+
Added:
in
1689
+
Added:
Alcotest.(check int) "the rested begin succeeds" 303 (status began);
1690
+
Added:
Alcotest.(check bool)
1691
+
Added:
"the rested begin reaches the workout" true
1692
+
Added:
(List.mem "/workout" (Dream.headers began "Location"));
1693
+
Added:
(* The cycle has advanced to Day 2, cleared on recovery, not an
1694
+
Added:
override. *)
1695
+
Added:
let workout =
1696
+
Added:
match
1697
+
Added:
Lwt_main.run (Memory_repo.in_progress repo trainee.Trainee.id)
1698
+
Added:
with
1699
+
Added:
| Some w -> w
1700
+
Added:
| None -> Alcotest.failf "expected a workout in progress"
1701
+
Added:
in
1702
+
Added:
Alcotest.(check string)
1703
+
Added:
"the cycle advanced to Day 2" "Day 2"
1704
+
Added:
(Prescription.Workout.name
1705
+
Added:
(Evidence.Workout.prescription workout));
1706
+
Added:
Alcotest.(check bool)
1707
+
Added:
"cleared on recovery, not an override" true
1708
+
Added:
(match Recovery.basis (Evidence.Workout.clearance workout) with
1709
+
Added:
| Recovery.Recovered -> true
1710
+
Added:
| Recovery.Overridden _ -> false) );
1711
+
Added:
] );
1712
+
Added:
( "web.isolation",
1713
+
Added:
[
1714
+
Added:
( "two trainees never share workout or logbook state",
1715
+
Added:
`Quick,
1716
+
Added:
fun () ->
1717
+
Added:
(* Two trainees on the same store, each with their own cookie jar.
1718
+
Added:
One begins and finishes a workout; the other must see none of it:
1719
+
Added:
not the in-progress workout, not the saved record. This proves
1720
+
Added:
the per-trainee scoping the service promises, over HTTP. *)
1721
+
Added:
let alice, repo = client_with_repo () in
1722
+
Added:
let _ = register alice ~username:"alice" ~password:"heavyduty1" in
1723
+
Added:
let bob = { alice with jar = [] } in
1724
+
Added:
let _ = register bob ~username:"bobby" ~password:"heavyduty1" in
1725
+
Added:
let alice_t = the_trainee repo ~username:"alice" in
1726
+
Added:
let bob_t = the_trainee repo ~username:"bobby" in
1727
+
Added:
(* Alice selects and begins a workout, records a slot. *)
1728
+
Added:
let token = Option.get (csrf_token (body (get alice "/"))) in
1729
+
Added:
let _ =
1730
+
Added:
post alice "/routines/ideal/select" [ ("dream.csrf", token) ]
1731
+
Added:
in
1732
+
Added:
let token = Option.get (csrf_token (body (get alice "/"))) in
1733
+
Added:
let _ =
1734
+
Added:
post alice "/workout"
1735
+
Added:
[ ("dream.csrf", token); ("override", "false") ]
1736
+
Added:
in
1737
+
Added:
let token =
1738
+
Added:
Option.get (csrf_token (body (get alice "/workout?slot=1")))
1739
+
Added:
in
1740
+
Added:
let _ =
1741
+
Added:
post alice "/workout/slots/1"
1742
+
Added:
[
1743
+
Added:
("dream.csrf", token);
1744
+
Added:
("load", "12");
1745
+
Added:
("reps", "8");
1746
+
Added:
("extension", "");
1747
+
Added:
]
1748
+
Added:
in
1749
+
Added:
(* Alice has a workout in progress; Bob does not. *)
1750
+
Added:
Alcotest.(check bool)
1751
+
Added:
"Alice has a workout in progress" true
1752
+
Added:
(Option.is_some
1753
+
Added:
(Lwt_main.run
1754
+
Added:
(Memory_repo.in_progress repo alice_t.Trainee.id)));
1755
+
Added:
Alcotest.(check bool)
1756
+
Added:
"Bob has no workout in progress" true
1757
+
Added:
(Option.is_none
1758
+
Added:
(Lwt_main.run (Memory_repo.in_progress repo bob_t.Trainee.id)));
1759
+
Added:
Alcotest.(check int)
1760
+
Added:
"Bob's workout view redirects" 303
1761
+
Added:
(status (get bob "/workout"));
1762
+
Added:
(* Alice finishes; her record becomes w1 for her. *)
1763
+
Added:
let token = Option.get (csrf_token (body (get alice "/workout"))) in
1764
+
Added:
let _ = post alice "/workout/finish" [ ("dream.csrf", token) ] in
1765
+
Added:
Alcotest.(check int)
1766
+
Added:
"Alice has one saved workout" 1
1767
+
Added:
(List.length
1768
+
Added:
(Lwt_main.run (Memory_repo.history repo alice_t.Trainee.id)));
1769
+
Added:
Alcotest.(check int)
1770
+
Added:
"Alice sees her saved workout" 200
1771
+
Added:
(status (get alice "/logbook/w1"));
1772
+
Added:
(* Bob's history is empty, and Alice's record is unreachable from
1773
+
Added:
Bob's session. Ids are per-trainee, so Bob's own w1 does not
1774
+
Added:
exist. *)
1775
+
Added:
Alcotest.(check int)
1776
+
Added:
"Bob's history is empty" 0
1777
+
Added:
(List.length
1778
+
Added:
(Lwt_main.run (Memory_repo.history repo bob_t.Trainee.id)));
2377
1779
Alcotest.(check int)
2378
1780
"Bob cannot open a record he does not own" 404
2379
1781
(status (get bob "/logbook/w1")) );