View raw

1 open Hito_app 2 open Support 3 4 let suite = 5 [ 6 ( "web.import", 7 (* The Hevy import web flow, end to end. Each test drives the multipart 8 upload and the review POSTs, then asserts status, redirect location, 9 and Repository state — never rendered HTML. The CSRF token is read from 10 the profile page body, as a browser would. *) 11 let header = 12 "title,start_time,end_time,description,exercise_title,superset_id,exercise_notes,set_index,set_type,weight_kg,reps,distance_km,duration_seconds,rpe\r\n" 13 in 14 (* One native row. Datetimes are Hevy local wall time. *) 15 let row ~title ~exercise ~set_type ~weight ~reps ?(set_index = 0) 16 ?(start = "01 Jan 2024, 10:00") ?(finish = "01 Jan 2024, 11:00") () = 17 Printf.sprintf "%s,%S,%S,,%s,,,%d,%s,%s,%d,,,\r\n" title start finish 18 exercise set_index set_type weight reps 19 in 20 let csv rows = header ^ String.concat "" rows in 21 (* Register, then read a CSRF token from the profile page. *) 22 let signed_in_profile_token c = 23 let _ = register c ~username:"lifter" ~password:"heavyduty1" in 24 Option.get (csrf_token (body (get c "/profile"))) 25 in 26 let review_token c url = Option.get (csrf_token (body (get c url))) in 27 let batches repo trainee = 28 Lwt_main.run (Test_repo.import_batches repo trainee.Trainee.id) 29 in 30 let imported repo trainee = 31 Lwt_main.run (Test_repo.imported_records repo trainee.Trainee.id) 32 in 33 [ 34 ( "a valid native export uploads to a stored batch with immutable source", 35 `Quick, 36 fun () -> 37 let c, repo = client_with_repo ~import_open:true () in 38 let token = signed_in_profile_token c in 39 let trainee = the_trainee repo ~username:"lifter" in 40 let source = 41 csv 42 [ 43 row ~title:"Legs" ~exercise:"Barbell Squat" 44 ~set_type:"failure" ~weight:"100" ~reps:8 (); 45 ] 46 in 47 let response = 48 post_multipart c "/profile/import" 49 ~file:(Some ("csv", "workouts.csv", source)) 50 ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] 51 in 52 Alcotest.(check int) "303 to review" 303 (status response); 53 Alcotest.(check bool) 54 "redirects to the review URL" true 55 (index_from ~needle:"/profile/import/" (location response) 0 56 |> Option.is_some); 57 let stored = batches repo trainee in 58 Alcotest.(check int) "one batch stored" 1 (List.length stored); 59 Alcotest.(check string) 60 "source retained verbatim" source 61 (Workout_import.batch_source (List.hd stored)) ); 62 ( "a duplicate upload is refused, the override stores a second batch", 63 `Quick, 64 fun () -> 65 let c, repo = client_with_repo ~import_open:true () in 66 let token = signed_in_profile_token c in 67 let trainee = the_trainee repo ~username:"lifter" in 68 let source = 69 csv 70 [ 71 row ~title:"Legs" ~exercise:"Barbell Squat" 72 ~set_type:"failure" ~weight:"100" ~reps:8 (); 73 ] 74 in 75 let upload ?(allow = false) () = 76 let token = Option.get (csrf_token (body (get c "/profile"))) in 77 let fields = 78 [ ("dream.csrf", token); ("utc_offset_seconds", "0") ] 79 @ if allow then [ ("allow_duplicate", "true") ] else [] 80 in 81 post_multipart c "/profile/import" 82 ~file:(Some ("csv", "workouts.csv", source)) 83 ~fields 84 in 85 ignore token; 86 let first = upload () in 87 Alcotest.(check int) "first upload 303" 303 (status first); 88 let second = upload () in 89 Alcotest.(check int) 90 "duplicate redirects to profile" 303 (status second); 91 Alcotest.(check string) 92 "duplicate lands on profile" "/profile" (location second); 93 Alcotest.(check int) 94 "still one batch after duplicate" 1 95 (List.length (batches repo trainee)); 96 let overridden = upload ~allow:true () in 97 Alcotest.(check int) "override 303" 303 (status overridden); 98 let stored = batches repo trainee in 99 Alcotest.(check int) 100 "override stores a second batch" 2 (List.length stored); 101 (* The two batches have distinct ids, so the override did not 102 overwrite the original. *) 103 let ids = 104 List.map 105 (fun b -> 106 Workout_import.batch_id_to_string 107 (Workout_import.batch_identity b)) 108 stored 109 in 110 Alcotest.(check bool) 111 "the two batch ids differ" true 112 (match ids with [ a; b ] -> a <> b | _ -> false) ); 113 ( "warmup and unsupported rows persist as warnings, not sets", 114 `Quick, 115 fun () -> 116 let c, repo = client_with_repo ~import_open:true () in 117 let token = signed_in_profile_token c in 118 let trainee = the_trainee repo ~username:"lifter" in 119 let source = 120 csv 121 [ 122 row ~title:"Legs" ~exercise:"Barbell Squat" ~set_type:"warmup" 123 ~weight:"40" ~reps:10 ~set_index:0 (); 124 row ~title:"Legs" ~exercise:"Barbell Squat" ~set_type:"wibble" 125 ~weight:"50" ~reps:10 ~set_index:1 (); 126 row ~title:"Legs" ~exercise:"Barbell Squat" 127 ~set_type:"failure" ~weight:"100" ~reps:8 ~set_index:2 (); 128 ] 129 in 130 let response = 131 post_multipart c "/profile/import" 132 ~file:(Some ("csv", "workouts.csv", source)) 133 ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] 134 in 135 Alcotest.(check int) "303 to review" 303 (status response); 136 let batch = List.hd (batches repo trainee) in 137 (* Two warnings: the warmup and the unknown set type. *) 138 Alcotest.(check int) 139 "two rows dropped as warnings" 2 140 (List.length (Workout_import.batch_warnings batch)); 141 (* The workout keeps only the one failure set; no provisional set 142 was invented for the dropped rows. *) 143 let w = List.hd (Workout_import.batch_workouts batch) in 144 Alcotest.(check int) 145 "only the failure set survives" 1 146 (List.length (Workout_import.workout_sets w)) ); 147 ( "map, confirm, then promote writes an imported record", 148 `Quick, 149 fun () -> 150 let c, repo = client_with_repo ~import_open:true () in 151 let token = signed_in_profile_token c in 152 let trainee = the_trainee repo ~username:"lifter" in 153 (* A single normal set: it needs both a mapping and a normal-set 154 confirmation before it can promote. *) 155 let source = 156 csv 157 [ 158 row ~title:"Legs" ~exercise:"Barbell Squat" ~set_type:"normal" 159 ~weight:"100" ~reps:8 (); 160 ] 161 in 162 let _ = 163 post_multipart c "/profile/import" 164 ~file:(Some ("csv", "workouts.csv", source)) 165 ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] 166 in 167 let batch = List.hd (batches repo trainee) in 168 let bid = 169 Workout_import.batch_id_to_string 170 (Workout_import.batch_identity batch) 171 in 172 let wid = 173 Workout_import.workout_id_to_string 174 (Workout_import.workout_identity 175 (List.hd (Workout_import.batch_workouts batch))) 176 in 177 let review = "/profile/import/" ^ bid in 178 let map_url = 179 Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid 180 in 181 let t = review_token c review in 182 let _ = 183 post c map_url 184 [ 185 ("dream.csrf", t); 186 ("source_name", "Barbell Squat"); 187 ("exercise_id", "squats"); 188 ] 189 in 190 let t = review_token c review in 191 let confirm_url = 192 Printf.sprintf "/profile/import/%s/workouts/%s/confirm" bid wid 193 in 194 let _ = 195 post c confirm_url 196 [ ("dream.csrf", t); ("acknowledgement", "clean form") ] 197 in 198 let t = review_token c review in 199 let promote_url = 200 Printf.sprintf "/profile/import/%s/workouts/%s/promote" bid wid 201 in 202 let promote = post c promote_url [ ("dream.csrf", t) ] in 203 Alcotest.(check int) "promotion redirects" 303 (status promote); 204 Alcotest.(check int) 205 "one imported record exists" 1 206 (List.length (imported repo trainee)) ); 207 ( "a dropset-only workout promotes nothing, writing no record", 208 `Quick, 209 fun () -> 210 (* A drop set is discarded on import — HD1 keeps only the drive to 211 failure. A workout of nothing but a drop set therefore has no 212 promotable set and writes no record. *) 213 let c, repo = client_with_repo ~import_open:true () in 214 let token = signed_in_profile_token c in 215 let trainee = the_trainee repo ~username:"lifter" in 216 let source = 217 csv 218 [ 219 row ~title:"Legs" ~exercise:"Barbell Squat" 220 ~set_type:"dropset" ~weight:"100" ~reps:8 (); 221 ] 222 in 223 let _ = 224 post_multipart c "/profile/import" 225 ~file:(Some ("csv", "workouts.csv", source)) 226 ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] 227 in 228 let batch = List.hd (batches repo trainee) in 229 let bid = 230 Workout_import.batch_id_to_string 231 (Workout_import.batch_identity batch) 232 in 233 let wid = 234 Workout_import.workout_id_to_string 235 (Workout_import.workout_identity 236 (List.hd (Workout_import.batch_workouts batch))) 237 in 238 let review = "/profile/import/" ^ bid in 239 (* Even mapping the discarded set's exercise cannot make it promote. *) 240 let t = review_token c review in 241 let _ = 242 post c 243 (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid) 244 [ 245 ("dream.csrf", t); 246 ("source_name", "Barbell Squat"); 247 ("exercise_id", "squats"); 248 ] 249 in 250 let t = review_token c review in 251 let promote = 252 post c 253 (Printf.sprintf "/profile/import/%s/workouts/%s/promote" bid wid) 254 [ ("dream.csrf", t) ] 255 in 256 Alcotest.(check int) "still a redirect" 303 (status promote); 257 Alcotest.(check int) 258 "no record written for a dropset-only workout" 0 259 (List.length (imported repo trainee)) ); 260 ( "a partial batch promotion promotes the eligible sibling and retains \ 261 the blocked one", 262 `Quick, 263 fun () -> 264 let c, repo = client_with_repo ~import_open:true () in 265 let token = signed_in_profile_token c in 266 let trainee = the_trainee repo ~username:"lifter" in 267 (* Two workouts: one ready failure set, one dropset-only workout 268 that promotes nothing once its drop set is discarded. *) 269 let source = 270 csv 271 [ 272 row ~title:"Legs" ~exercise:"Barbell Squat" 273 ~set_type:"failure" ~weight:"100" ~reps:8 274 ~start:"01 Jan 2024, 10:00" ~finish:"01 Jan 2024, 11:00" (); 275 row ~title:"Arms" ~exercise:"Barbell Curl" ~set_type:"dropset" 276 ~weight:"30" ~reps:8 ~start:"02 Jan 2024, 10:00" 277 ~finish:"02 Jan 2024, 11:00" (); 278 ] 279 in 280 let _ = 281 post_multipart c "/profile/import" 282 ~file:(Some ("csv", "workouts.csv", source)) 283 ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] 284 in 285 let batch = List.hd (batches repo trainee) in 286 let bid = 287 Workout_import.batch_id_to_string 288 (Workout_import.batch_identity batch) 289 in 290 let review = "/profile/import/" ^ bid in 291 (* Map the ready workout's exercise so its only blocker clears. *) 292 let ready_wid = 293 List.find_map 294 (fun w -> 295 if String.equal (Workout_import.workout_title w) "Legs" then 296 Some 297 (Workout_import.workout_id_to_string 298 (Workout_import.workout_identity w)) 299 else None) 300 (Workout_import.batch_workouts batch) 301 |> Option.get 302 in 303 let t = review_token c review in 304 let _ = 305 post c 306 (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid 307 ready_wid) 308 [ 309 ("dream.csrf", t); 310 ("source_name", "Barbell Squat"); 311 ("exercise_id", "squats"); 312 ] 313 in 314 let t = review_token c review in 315 let promote = 316 post c 317 (Printf.sprintf "/profile/import/%s/promote" bid) 318 [ ("dream.csrf", t) ] 319 in 320 Alcotest.(check int) 321 "batch promotion redirects" 303 (status promote); 322 (* Only the eligible sibling is promoted. The dropset-only workout 323 has nothing to promote, writes no record, and stays in the 324 batch. *) 325 Alcotest.(check int) 326 "one record from the eligible sibling" 1 327 (List.length (imported repo trainee)) ); 328 ( "an unknown batch GET is 404, an unknown exercise mapping changes \ 329 nothing", 330 `Quick, 331 fun () -> 332 let c, repo = client_with_repo ~import_open:true () in 333 let token = signed_in_profile_token c in 334 let trainee = the_trainee repo ~username:"lifter" in 335 (* Unknown batch id: the review GET is a 404. *) 336 ignore token; 337 Alcotest.(check int) 338 "unknown batch review is 404" 404 339 (status (get c "/profile/import/no-such-batch")); 340 (* Upload a real batch, then map to an id absent from the catalog. 341 No mapping is recorded. *) 342 let source = 343 csv 344 [ 345 row ~title:"Legs" ~exercise:"Barbell Squat" ~set_type:"normal" 346 ~weight:"100" ~reps:8 (); 347 ] 348 in 349 let up_token = Option.get (csrf_token (body (get c "/profile"))) in 350 let _ = 351 post_multipart c "/profile/import" 352 ~file:(Some ("csv", "workouts.csv", source)) 353 ~fields: 354 [ ("dream.csrf", up_token); ("utc_offset_seconds", "0") ] 355 in 356 let batch = List.hd (batches repo trainee) in 357 let bid = 358 Workout_import.batch_id_to_string 359 (Workout_import.batch_identity batch) 360 in 361 let wid = 362 Workout_import.workout_id_to_string 363 (Workout_import.workout_identity 364 (List.hd (Workout_import.batch_workouts batch))) 365 in 366 let review = "/profile/import/" ^ bid in 367 let t = review_token c review in 368 let response = 369 post c 370 (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid) 371 [ 372 ("dream.csrf", t); 373 ("source_name", "Barbell Squat"); 374 ("exercise_id", "not-an-exercise"); 375 ] 376 in 377 Alcotest.(check int) 378 "unknown exercise redirects, not errors" 303 (status response); 379 (* The stored workout still has no mapping for the source name. *) 380 let reloaded = List.hd (batches repo trainee) in 381 let w = List.hd (Workout_import.batch_workouts reloaded) in 382 Alcotest.(check bool) 383 "no mapping was recorded" true 384 (Option.is_none 385 (Workout_import.workout_mapping w ~source_name:"Barbell Squat")) 386 ); 387 ] ); 388 ] 389