Workout results — QA plan
Pre-requisites
- Pro-tier org with library workouts:
- “Fran” — scoring=
time, 2 movements (Thruster + Pull-up). - “Back Squat 5x5” — scoring=
weight, 1 movement (Back Squat). - “AMRAP 12” — scoring=
rounds_reps. - “Easy Row 20” — scoring=
distance. - “No score” — scoring=
none.
- “Fran” — scoring=
- Personas: Coach Casey, Members Mia / Niv / Liat.
- An assignment of Fran on today’s date for Mia (pointer state, published).
Test scenarios
Logging — happy paths
-
Log a time result with assignment
- Trigger: Mia
POST /workouts/<fran>/results{ assignmentId, scoreValue:'5:42', rx:true }. - Expected:
score_numeric=342. Snapshot forked from Fran on first edit/completion. Assignment status→completed.isPR=true(first-ever). PR row inpersonal_recordskeyed by(mia, fran library id).
- Trigger: Mia
-
Log a weight result on single-movement workout
- Trigger: Mia
POST /workouts/<backsquat5x5>/results{ scoreValue:'120', scoreUnit:'kg', setResults:[{exerciseId, setNumber:1, reps:5, weight:'120', weightUnit:'kg'}, ... ×5] }. - Expected: result row + 5 set rows. Both workout-level AND exercise-level PR upserted in
personal_records.
- Trigger: Mia
-
Log an AMRAP result
- Trigger:
scoreValue:'5+12'on a workout with scoring=rounds_reps. - Expected:
score_numeric=5012. Display formatter renders back5+12.
- Trigger:
-
Log distance with non-metric unit
- Trigger:
setResults:[{ exerciseId, setNumber:1, distance:'5', distanceUnit:'km' }]. - Expected:
distance_m=5000,distance_display_unit='km'.
- Trigger:
-
Log weight in lb
- Trigger:
weight:'225', weightUnit:'lb'. - Expected:
weight_kg ≈ 102.058,weight_display_unit='lb'. Round-trips back to 225 lb in UI.
- Trigger:
-
Log duration with mm:ss
- Trigger:
setResults:[{ ..., duration:'1:30' }]. - Expected:
duration_seconds=90.
- Trigger:
-
Log result without assignment
- Trigger: Mia
POST /workouts/<fran>/results{ scoreValue:'6:00' }, noassignmentId. - Expected: 200; service falls back to matching an
assignedrow by (library, user, today). If a matching assignment exists, it auto-completes.
- Trigger: Mia
-
Log on a
scoring='none'workout- Trigger: scoreValue omitted.
- Expected: 200;
score_numeric=null;isPR=false.
PR detection
-
Improve a time PR
- Trigger: After step 1 (5:42), log a 5:30 result.
- Expected:
isPR=true. PR row updated (lower time = better).
-
Worse-than-PR time
- Trigger: log 5:50 after the 5:30 PR.
- Expected:
isPR=false. PR row unchanged.
-
Improve a weight PR
- Trigger: After step 2 (120kg), log 125kg.
- Expected:
isPR=true. Both PR rows (workout + exercise) updated.
-
Equal weight PR
- Trigger: Same 125kg again.
- Expected:
isPR=true(≥ comparison). TODO: verify whether tying counts.
-
Equal time PR
- Trigger: 5:30 after 5:30.
- Expected:
isPR=true(≤). TODO: verify.
-
PR detection across forks
- Trigger: Mia logs Fran via her snapshot. Niv logs Fran via the library row (different snapshot). Each gets their own PR; both reference the same
library_workout_idso the leaderboard composes them correctly.
- Trigger: Mia logs Fran via her snapshot. Niv logs Fran via the library row (different snapshot). Each gets their own PR; both reference the same
-
Manual PR by exercise
- Trigger: Mia
POST /personal-records/me{ exerciseId:<deadlift>, value:'180', unit:'kg' }. - Expected: row in
personal_recordswithworkout_result_id=null.
- Trigger: Mia
-
Manual PR upsert
- Trigger: Mia logs 185kg manual deadlift PR (after step 15).
- Expected: existing row updated (same partial unique index).
-
Manual PR with neither exerciseId nor workoutId
- Trigger: empty payload.
- Expected: 400.
Leaderboard
-
Leaderboard mixes snapshots and library results
- Trigger: Mia (snapshot), Niv (library), Liat (snapshot) all logged Fran.
GET /workouts/<fran>/results. - Expected: 3 rows, sorted Rx-first then by
score_numericascending (lower-time-better). Ranks 1, 2, 3.
- Trigger: Mia (snapshot), Niv (library), Liat (snapshot) all logged Fran.
-
Leaderboard sorts Rx ahead of scaled
- Trigger: Niv’s result is
rx:false, scaled:true. Mia’s isrx:true. - Expected: Mia comes first regardless of relative scores.
- Trigger: Niv’s result is
-
Leaderboard via snapshot id
- Trigger:
GET /workouts/<miaSnapshotId>/results. - Expected: same leaderboard (service resolves library id via
forkedFromId).
- Trigger:
Updates / deletes
-
Update own result
- Trigger: Mia
PATCH /workouts/<fran>/results/<rid>{ scoreValue:'5:25', notes:'felt great' }. - Expected: 200. Note: PR detection does NOT re-run on update (TODO: verify if this is a gap).
- Trigger: Mia
-
Update another user’s result
- Trigger: Niv
PATCH …/results/<mia_rid>. - Expected: 403 “Can only update your own results”.
- Trigger: Niv
-
Update with non-parseable score
- Trigger:
scoreValue:'fast'on a time workout. - Expected: 400.
- Trigger:
-
Delete own result
- Trigger: Mia
DELETE …/results/<rid>. - Expected: 200; soft-deleted. PR row for that result remains pointing at the now-deleted id (TODO: verify whether PR should also be invalidated).
- Trigger: Mia
-
Delete another user’s result
- Trigger: Niv
DELETE …/<mia_rid>. - Expected: 403.
- Trigger: Niv
Reads
-
My results feed
- Trigger:
GET /results/me. - Expected: all my results across all workouts, newest first.
- Trigger:
-
My latest with sets
- Trigger:
GET /workouts/<fran>/results/me/latest. - Expected: most recent Fran result with
setResults[]populated.
- Trigger:
-
No latest result
- Trigger: Member who never logged this workout.
- Expected: 200 with
nullbody.
-
Member result history (staff)
- Trigger: Casey
GET /members/:membershipId/results. - Expected: full history of that member.
- Trigger: Casey
-
Member result history (peer member)
- Trigger: Niv tries the same endpoint.
- Expected: 403 “Insufficient permissions”.
-
Member PRs (any role)
- Trigger: Niv
GET /members/:membershipId/personal-recordsfor Mia. - Expected: 200 — currently no privacy gate on PRs.
- Trigger: Niv
Permissions / edge
-
Cross-org workout in URL
- Trigger: Mia (orgA) calls
/organizations/<orgB>/workouts/<X>/results. - Expected: 403 from
requireMembership.
- Trigger: Mia (orgA) calls
-
Result auto-complete misfires on duplicate same-day assignment
- Trigger: Mia has two
assignedFran rows on the same day (slot 0 and slot 1). She logs a result withoutassignmentId. - Expected: UPDATE matches whichever row hits first — both could be flipped depending on the WHERE. TODO: verify behavior in code.
- Trigger: Mia has two
i18n
-
Result formatter respects locale
- Trigger: Member in
heviews her result of5:42. - Expected: numbers and units localized; RTL alignment.
- Trigger: Member in
-
PR badge translated
- Trigger: Trigger a PR while in Russian.
- Expected: PR badge text in Russian.
What “broken” looks like
- PR detected as
falsewhen the new score is the same as the best (off-by-one comparison). - Result anchored to library row instead of snapshot (post-completion edits leak back).
- Weight stored in
display_unitunits instead of canonical kg — chart math drifts. - Auto-completion runs UPDATE with no WHERE, flipping unrelated assignments to completed.
findByWorkoutreturns results sorted bycreated_atinstead ofscore_numeric(Rx ordering wrong).- PR upsert violates the partial unique index because the filter on
deleted_at IS NULLwas missed in the read step. - A result on a snapshot workout id raises 404 because the org check ran against
snapshot.organizationId(it should — both library and snapshot carry the same org). - Set conversion of
1lbrounds to 0kg (Number.isNaN(parseFloat('1lb'))false; conversion may stringify).