Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkout ResultsWorkout results — QA plan

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.
  • 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

  1. 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 in personal_records keyed by (mia, fran library id).
  2. 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.
  3. Log an AMRAP result

    • Trigger: scoreValue:'5+12' on a workout with scoring=rounds_reps.
    • Expected: score_numeric=5012. Display formatter renders back 5+12.
  4. Log distance with non-metric unit

    • Trigger: setResults:[{ exerciseId, setNumber:1, distance:'5', distanceUnit:'km' }].
    • Expected: distance_m=5000, distance_display_unit='km'.
  5. 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.
  6. Log duration with mm:ss

    • Trigger: setResults:[{ ..., duration:'1:30' }].
    • Expected: duration_seconds=90.
  7. Log result without assignment

    • Trigger: Mia POST /workouts/<fran>/results { scoreValue:'6:00' }, no assignmentId.
    • Expected: 200; service falls back to matching an assigned row by (library, user, today). If a matching assignment exists, it auto-completes.
  8. Log on a scoring='none' workout

    • Trigger: scoreValue omitted.
    • Expected: 200; score_numeric=null; isPR=false.

PR detection

  1. Improve a time PR

    • Trigger: After step 1 (5:42), log a 5:30 result.
    • Expected: isPR=true. PR row updated (lower time = better).
  2. Worse-than-PR time

    • Trigger: log 5:50 after the 5:30 PR.
    • Expected: isPR=false. PR row unchanged.
  3. Improve a weight PR

    • Trigger: After step 2 (120kg), log 125kg.
    • Expected: isPR=true. Both PR rows (workout + exercise) updated.
  4. Equal weight PR

    • Trigger: Same 125kg again.
    • Expected: isPR=true (≥ comparison). TODO: verify whether tying counts.
  5. Equal time PR

    • Trigger: 5:30 after 5:30.
    • Expected: isPR=true (≤). TODO: verify.
  6. 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_id so the leaderboard composes them correctly.
  7. Manual PR by exercise

    • Trigger: Mia POST /personal-records/me { exerciseId:<deadlift>, value:'180', unit:'kg' }.
    • Expected: row in personal_records with workout_result_id=null.
  8. Manual PR upsert

    • Trigger: Mia logs 185kg manual deadlift PR (after step 15).
    • Expected: existing row updated (same partial unique index).
  9. Manual PR with neither exerciseId nor workoutId

    • Trigger: empty payload.
    • Expected: 400.

Leaderboard

  1. 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_numeric ascending (lower-time-better). Ranks 1, 2, 3.
  2. Leaderboard sorts Rx ahead of scaled

    • Trigger: Niv’s result is rx:false, scaled:true. Mia’s is rx:true.
    • Expected: Mia comes first regardless of relative scores.
  3. Leaderboard via snapshot id

    • Trigger: GET /workouts/<miaSnapshotId>/results.
    • Expected: same leaderboard (service resolves library id via forkedFromId).

Updates / deletes

  1. 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).
  2. Update another user’s result

    • Trigger: Niv PATCH …/results/<mia_rid>.
    • Expected: 403 “Can only update your own results”.
  3. Update with non-parseable score

    • Trigger: scoreValue:'fast' on a time workout.
    • Expected: 400.
  4. 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).
  5. Delete another user’s result

    • Trigger: Niv DELETE …/<mia_rid>.
    • Expected: 403.

Reads

  1. My results feed

    • Trigger: GET /results/me.
    • Expected: all my results across all workouts, newest first.
  2. My latest with sets

    • Trigger: GET /workouts/<fran>/results/me/latest.
    • Expected: most recent Fran result with setResults[] populated.
  3. No latest result

    • Trigger: Member who never logged this workout.
    • Expected: 200 with null body.
  4. Member result history (staff)

    • Trigger: Casey GET /members/:membershipId/results.
    • Expected: full history of that member.
  5. Member result history (peer member)

    • Trigger: Niv tries the same endpoint.
    • Expected: 403 “Insufficient permissions”.
  6. Member PRs (any role)

    • Trigger: Niv GET /members/:membershipId/personal-records for Mia.
    • Expected: 200 — currently no privacy gate on PRs.

Permissions / edge

  1. Cross-org workout in URL

    • Trigger: Mia (orgA) calls /organizations/<orgB>/workouts/<X>/results.
    • Expected: 403 from requireMembership.
  2. Result auto-complete misfires on duplicate same-day assignment

    • Trigger: Mia has two assigned Fran rows on the same day (slot 0 and slot 1). She logs a result without assignmentId.
    • Expected: UPDATE matches whichever row hits first — both could be flipped depending on the WHERE. TODO: verify behavior in code.

i18n

  1. Result formatter respects locale

    • Trigger: Member in he views her result of 5:42.
    • Expected: numbers and units localized; RTL alignment.
  2. PR badge translated

    • Trigger: Trigger a PR while in Russian.
    • Expected: PR badge text in Russian.

What “broken” looks like

  • PR detected as false when 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_unit units instead of canonical kg — chart math drifts.
  • Auto-completion runs UPDATE with no WHERE, flipping unrelated assignments to completed.
  • findByWorkout returns results sorted by created_at instead of score_numeric (Rx ordering wrong).
  • PR upsert violates the partial unique index because the filter on deleted_at IS NULL was 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 1lb rounds to 0kg (Number.isNaN(parseFloat('1lb')) false; conversion may stringify).