[OCaml] High Intensity Training Online
1
(** Tests for {!Hito_app.Sqlite_repo} — the durable store. Proves that state
2
survives reconnecting to the same file, and that trainees stay isolated. *)
3
4
open Hito_app
5
module S = Service.Make (Sqlite_repo)
6
module Stimulus = Evidence.Stimulus
7
module Workout = Evidence.Workout
8
9
let run = Lwt_main.run
10
let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
11
12
let get id =
13
match Exercise.find_id id with
14
| Some e -> e
15
| None -> Alcotest.failf "catalog is missing %S" id
16
17
let at s = Recovery.timestamp_of_unix_seconds s
18
let day n = at (n * 86_400)
19
let ideal = Repository.routine_id "ideal"
20
21
let single id load reps =
22
Stimulus.make
23
(Stimulus.Single
24
(Stimulus.Effort.make ~exercise:(get id) ~load ~reps
25
~outcome:Stimulus.Positive_failure))
26
27
(* A unique temp database file per test; SQLite is a plain file. *)
28
let temp_uri () =
29
let path = Filename.temp_file "hito-test-" ".sqlite" in
30
Sys.remove path;
31
(path, "sqlite3:" ^ path)
32
33
let connect uri = ok (run (Sqlite_repo.connect uri))
34
35
let cleanup path =
36
List.iter
37
(fun suffix -> try Sys.remove (path ^ suffix) with Sys_error _ -> ())
38
[ ""; "-wal"; "-shm" ]
39
40
let durability_tests =
41
[
42
( "a finished workout survives a reconnect to the same file",
43
`Quick,
44
fun () ->
45
let path, uri = temp_uri () in
46
Fun.protect
47
~finally:(fun () -> cleanup path)
48
(fun () ->
49
let trainee_id =
50
let repo = connect uri in
51
let s = S.make ~repo in
52
let trainee =
53
ok (run (S.register s ~username:"alice" ~password:"heavyduty1"))
54
in
55
let id = trainee.Trainee.id in
56
let _ =
57
ok (run (S.begin_workout s id ~routine:ideal ~now:(day 1) ()))
58
in
59
let _ = ok (run (S.log s id (single "laterals" 12. 8))) in
60
let _ = run (S.finish s id ~ended_at:(day 1)) in
61
id
62
in
63
(* Reconnect with a fresh repo value to the same file. *)
64
let repo = connect uri in
65
let s = S.make ~repo in
66
let found =
67
run (S.authenticate s ~username:"alice" ~password:"heavyduty1")
68
in
69
Alcotest.(check bool)
70
"account persisted" true (Option.is_some found);
71
Alcotest.(check string)
72
"same trainee id"
73
(Trainee.id_to_string trainee_id)
74
(Trainee.id_to_string (Option.get found).Trainee.id);
75
Alcotest.(check int)
76
"history persisted" 1
77
(List.length (run (S.history s trainee_id)))) );
78
( "a new trainee gets a fresh identity after reconnect",
79
`Quick,
80
fun () ->
81
let path, uri = temp_uri () in
82
Fun.protect
83
~finally:(fun () -> cleanup path)
84
(fun () ->
85
let first_id =
86
let repo = connect uri in
87
let s = S.make ~repo in
88
let trainee =
89
ok (run (S.register s ~username:"alice" ~password:"first"))
90
in
91
Trainee.id_to_string trainee.Trainee.id
92
in
93
let repo = connect uri in
94
let s = S.make ~repo in
95
let second =
96
ok (run (S.register s ~username:"bobby" ~password:"second"))
97
in
98
let second_id = Trainee.id_to_string second.Trainee.id in
99
Alcotest.(check bool)
100
"identity is not reused" false
101
(String.equal first_id second_id);
102
Alcotest.(check string) "sequence continues" "t2" second_id) );
103
( "the workout in progress is durable and per-trainee",
104
`Quick,
105
fun () ->
106
let path, uri = temp_uri () in
107
Fun.protect
108
~finally:(fun () -> cleanup path)
109
(fun () ->
110
let repo = connect uri in
111
let s = S.make ~repo in
112
let a =
113
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
114
.Trainee.id
115
in
116
let b =
117
(ok (run (S.register s ~username:"bobby" ~password:"heavyduty1")))
118
.Trainee.id
119
in
120
let _ =
121
ok (run (S.begin_workout s a ~routine:ideal ~now:(day 1) ()))
122
in
123
(* Reconnect and confirm a's slot survives while b's stays empty. *)
124
let repo = connect uri in
125
let s = S.make ~repo in
126
Alcotest.(check bool)
127
"a has a workout in progress" true
128
(Option.is_some (run (S.in_progress s a)));
129
Alcotest.(check bool)
130
"b does not" true
131
(Option.is_none (run (S.in_progress s b)))) );
132
( "a saved record can be completed later and stays complete",
133
`Quick,
134
fun () ->
135
let path, uri = temp_uri () in
136
Fun.protect
137
~finally:(fun () -> cleanup path)
138
(fun () ->
139
let repo = connect uri in
140
let s = S.make ~repo in
141
let t =
142
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
143
.Trainee.id
144
in
145
let _ =
146
ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ()))
147
in
148
let record = Option.get (run (S.finish s t ~ended_at:(at 120))) in
149
let record =
150
ok
151
(run
152
(S.add_to_record s t record.Repository.id
153
(single "laterals" 12. 8)))
154
in
155
(* Reconnect and read it back. *)
156
let repo = connect uri in
157
let s = S.make ~repo in
158
let reread =
159
Option.get (run (S.find_record s t record.Repository.id))
160
in
161
Alcotest.(check int)
162
"one stimulus survived" 1
163
(List.length (Workout.stimuli reread.Repository.workout))) );
164
( "a corrected slot survives a reconnect without adding volume",
165
`Quick,
166
fun () ->
167
let path, uri = temp_uri () in
168
Fun.protect
169
~finally:(fun () -> cleanup path)
170
(fun () ->
171
let repo = connect uri in
172
let s = S.make ~repo in
173
let t =
174
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
175
.Trainee.id
176
in
177
let _ =
178
ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ()))
179
in
180
let _ = ok (run (S.log s t (single "laterals" 12. 8))) in
181
let record = Option.get (run (S.finish s t ~ended_at:(at 120))) in
182
let _ =
183
ok
184
(run
185
(S.replace_in_record s t record.Repository.id ~slot:1
186
(single "laterals" 15. 6)))
187
in
188
let repo = connect uri in
189
let s = S.make ~repo in
190
let reread =
191
Option.get (run (S.find_record s t record.Repository.id))
192
in
193
Alcotest.(check int)
194
"still one stimulus" 1
195
(List.length (Workout.stimuli reread.Repository.workout));
196
Alcotest.(check (float 0.001))
197
"corrected load persisted" 15.
198
(Stimulus.Effort.load
199
(List.hd
200
(Stimulus.efforts
201
(List.hd (Workout.stimuli reread.Repository.workout))))))
202
);
203
( "a short password persists and authenticates after a reconnect",
204
`Quick,
205
fun () ->
206
let path, uri = temp_uri () in
207
Fun.protect
208
~finally:(fun () -> cleanup path)
209
(fun () ->
210
let repo = connect uri in
211
let s = S.make ~repo in
212
(* No password policy: a one-character password is stored and later
213
verifies against its persisted hash. *)
214
let _ = ok (run (S.register s ~username:"alice" ~password:"x")) in
215
let repo = connect uri in
216
let s = S.make ~repo in
217
Alcotest.(check bool)
218
"short password authenticates after reconnect" true
219
(Option.is_some
220
(run (S.authenticate s ~username:"alice" ~password:"x")))) );
221
]
222
223
let migration_tests =
224
[
225
( "applying migrations twice to one file is idempotent",
226
`Quick,
227
fun () ->
228
let path, uri = temp_uri () in
229
Fun.protect
230
~finally:(fun () -> cleanup path)
231
(fun () ->
232
(* First connect creates and records the schema. *)
233
let _ = connect uri in
234
(* A second connect on the same file must not fail re-applying an
235
already-recorded migration. *)
236
let repo = connect uri in
237
let s = S.make ~repo in
238
let trainee =
239
ok (run (S.register s ~username:"alice" ~password:"heavyduty1"))
240
in
241
Alcotest.(check bool)
242
"usable after reconnect" true
243
(Option.is_some
244
(run
245
(S.authenticate s ~username:"alice" ~password:"heavyduty1")));
246
ignore trainee) );
247
( "finishing is atomic: the record is saved and the slot cleared",
248
`Quick,
249
fun () ->
250
let path, uri = temp_uri () in
251
Fun.protect
252
~finally:(fun () -> cleanup path)
253
(fun () ->
254
let repo = connect uri in
255
let s = S.make ~repo in
256
let t =
257
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
258
.Trainee.id
259
in
260
let _ =
261
ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ()))
262
in
263
let _ = run (S.finish s t ~ended_at:(day 1)) in
264
(* After a reconnect both effects of finish are visible together:
265
the workout is in history and no slot remains in progress. *)
266
let repo = connect uri in
267
let s = S.make ~repo in
268
Alcotest.(check int)
269
"one workout in history" 1
270
(List.length (run (S.history s t)));
271
Alcotest.(check bool)
272
"slot cleared" true
273
(Option.is_none (run (S.in_progress s t)))) );
274
( "subjective feedback survives a reconnect",
275
`Quick,
276
fun () ->
277
let path, uri = temp_uri () in
278
Fun.protect
279
~finally:(fun () -> cleanup path)
280
(fun () ->
281
let t =
282
let repo = connect uri in
283
let s = S.make ~repo in
284
let id =
285
(ok
286
(run (S.register s ~username:"alice" ~password:"heavyduty1")))
287
.Trainee.id
288
in
289
let _ =
290
ok
291
(run
292
(S.record_feedback s id ~reported_at:(day 1)
293
[
294
Evidence.Feedback.Sleep Evidence.Feedback.Poor;
295
Evidence.Feedback.Pain;
296
]))
297
in
298
id
299
in
300
(* Reconnect: the feedback report is still there, with its
301
signals. *)
302
let repo = connect uri in
303
let s = S.make ~repo in
304
let reports = run (S.feedback s t) in
305
Alcotest.(check int) "one feedback report" 1 (List.length reports);
306
let signals = Evidence.Feedback.signals (List.hd reports) in
307
Alcotest.(check int) "two signals" 2 (List.length signals);
308
Alcotest.(check bool)
309
"sleep signal preserved" true
310
(List.mem (Evidence.Feedback.Sleep Evidence.Feedback.Poor) signals);
311
Alcotest.(check bool)
312
"pain signal preserved" true
313
(List.mem Evidence.Feedback.Pain signals)) );
314
( "application feedback survives a reconnect",
315
`Quick,
316
fun () ->
317
let path, uri = temp_uri () in
318
Fun.protect
319
~finally:(fun () -> cleanup path)
320
(fun () ->
321
let id =
322
let repo = connect uri in
323
let s = S.make ~repo in
324
let id =
325
(ok
326
(run (S.register s ~username:"alice" ~password:"heavyduty1")))
327
.Trainee.id
328
in
329
let _ =
330
ok
331
(run
332
(S.record_app_feedback s id ~submitted_at:(day 2)
333
~message:"The app feels clear."))
334
in
335
id
336
in
337
let repo = connect uri in
338
let s = S.make ~repo in
339
match run (S.app_feedback s id) with
340
| [ report ] ->
341
Alcotest.(check string)
342
"message persisted" "The app feels clear."
343
report.Repository.message;
344
Alcotest.(check int)
345
"timestamp persisted" (2 * 86_400)
346
(Recovery.timestamp_to_unix_seconds report.submitted_at)
347
| reports ->
348
Alcotest.failf "expected one report, got %d"
349
(List.length reports)) );
350
( "application feedback ranking and votes survive a reconnect",
351
`Quick,
352
fun () ->
353
let path, uri = temp_uri () in
354
Fun.protect
355
~finally:(fun () -> cleanup path)
356
(fun () ->
357
let bob_feedback_id =
358
let repo = connect uri in
359
let s = S.make ~repo in
360
let alice =
361
(ok
362
(run (S.register s ~username:"alice" ~password:"heavyduty1")))
363
.Trainee.id
364
in
365
let bob =
366
(ok
367
(run (S.register s ~username:"bobby" ~password:"heavyduty1")))
368
.Trainee.id
369
in
370
let _ =
371
ok
372
(run
373
(S.record_app_feedback s alice ~submitted_at:(day 1)
374
~message:"From Alice"))
375
in
376
let bob_report =
377
ok
378
(run
379
(S.record_app_feedback s bob ~submitted_at:(day 2)
380
~message:"From Bob"))
381
in
382
Alcotest.(check bool)
383
"vote is accepted" true
384
(run
385
(S.upvote_app_feedback s alice
386
bob_report.Repository.feedback_id));
387
bob_report.Repository.feedback_id
388
in
389
let repo = connect uri in
390
let s = S.make ~repo in
391
let alice =
392
Option.get
393
(run
394
(S.authenticate s ~username:"alice" ~password:"heavyduty1"))
395
in
396
let bob =
397
Option.get
398
(run
399
(S.authenticate s ~username:"bobby" ~password:"heavyduty1"))
400
in
401
let reports = run (S.app_feedback s alice.Trainee.id) in
402
let bob_report =
403
List.find
404
(fun report -> String.equal report.Repository.author "bobby")
405
reports
406
in
407
Alcotest.(check string)
408
"feedback identity"
409
(Repository.app_feedback_id_to_string bob_feedback_id)
410
(Repository.app_feedback_id_to_string
411
bob_report.Repository.feedback_id);
412
Alcotest.(check int)
413
"vote persisted" 1 bob_report.Repository.upvotes;
414
Alcotest.(check bool)
415
"viewer vote persisted" true bob_report.Repository.viewer_upvoted;
416
let own_view =
417
List.find
418
(fun report -> String.equal report.Repository.author "bobby")
419
(run (S.app_feedback s bob.Trainee.id))
420
in
421
Alcotest.(check bool)
422
"owner does not see a self vote" false
423
own_view.Repository.viewer_upvoted) );
424
( "application feedback CRUD survives and enforces ownership",
425
`Quick,
426
fun () ->
427
let path, uri = temp_uri () in
428
Fun.protect
429
~finally:(fun () -> cleanup path)
430
(fun () ->
431
let report_id =
432
let repo = connect uri in
433
let s = S.make ~repo in
434
let alice =
435
(ok
436
(run (S.register s ~username:"alice" ~password:"heavyduty1")))
437
.Trainee.id
438
in
439
let bob =
440
(ok
441
(run (S.register s ~username:"bobby" ~password:"heavyduty1")))
442
.Trainee.id
443
in
444
let report =
445
ok
446
(run
447
(S.record_app_feedback s alice ~submitted_at:(day 1)
448
~message:"Original"))
449
in
450
(match
451
run
452
(S.edit_app_feedback s bob report.Repository.feedback_id
453
~message:"Not yours")
454
with
455
| Error `Unknown_feedback -> ()
456
| _ -> Alcotest.fail "another trainee edited the report");
457
(match
458
run
459
(S.edit_app_feedback s alice report.Repository.feedback_id
460
~message:"Edited")
461
with
462
| Ok () -> ()
463
| _ -> Alcotest.fail "owner edit failed");
464
report.Repository.feedback_id
465
in
466
let repo = connect uri in
467
let s = S.make ~repo in
468
let alice =
469
Option.get
470
(run
471
(S.authenticate s ~username:"alice" ~password:"heavyduty1"))
472
in
473
let edited =
474
List.find
475
(fun report ->
476
String.equal
477
(Repository.app_feedback_id_to_string
478
report.Repository.feedback_id)
479
(Repository.app_feedback_id_to_string report_id))
480
(run (S.app_feedback s alice.Trainee.id))
481
in
482
Alcotest.(check string)
483
"edited message persists" "Edited" edited.Repository.message;
484
Alcotest.(check bool)
485
"owner remove succeeds" true
486
(run (S.remove_app_feedback s alice.Trainee.id report_id));
487
Alcotest.(check int)
488
"removed report is absent" 0
489
(List.length (run (S.app_feedback s alice.Trainee.id)))) );
490
( "a username and password change survive a reconnect",
491
`Quick,
492
fun () ->
493
let path, uri = temp_uri () in
494
Fun.protect
495
~finally:(fun () -> cleanup path)
496
(fun () ->
497
let id =
498
let repo = connect uri in
499
let s = S.make ~repo in
500
let id =
501
(ok
502
(run (S.register s ~username:"alice" ~password:"heavyduty1")))
503
.Trainee.id
504
in
505
let _ = ok (run (S.change_username s id ~username:"alicia")) in
506
let _ =
507
ok
508
(run
509
(S.change_password s id ~current:"heavyduty1"
510
~next:"newsecret1"))
511
in
512
id
513
in
514
(* Reconnect: the new name and password must be the ones stored. *)
515
let repo = connect uri in
516
let s = S.make ~repo in
517
(match run (S.find_trainee s id) with
518
| Some found ->
519
Alcotest.(check string)
520
"renamed account persisted" "alicia"
521
(Trainee.username_to_string found.Trainee.username)
522
| None -> Alcotest.fail "account vanished");
523
Alcotest.(check bool)
524
"the old password no longer authenticates" true
525
(Option.is_none
526
(run
527
(S.authenticate s ~username:"alicia" ~password:"heavyduty1")));
528
Alcotest.(check bool)
529
"the new password authenticates" true
530
(Option.is_some
531
(run
532
(S.authenticate s ~username:"alicia" ~password:"newsecret1"))))
533
);
534
]
535
536
let suite =
537
[
538
("sqlite_repo", durability_tests);
539
("sqlite_repo.migrations", migration_tests);
540
]
541