Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkoutsWorkouts — QA plan

Workouts — QA plan

Pre-requisites

  • Local stack running: docker compose up -d, then pnpm dev (API on 3001, web on 3000).
  • Seeded org with platform tier pro or elite (for structured-mode tests) and a second org on tier lite (for the tier-gate negative test).
  • Personas seeded:
    • Coach Caseycoach role in the pro org.
    • Owner Olgaowner role in the pro org.
    • Member Miamember role in the pro org.
    • Coach Lite Liorcoach role in the lite org.
  • Exercise library: at least 20 canonical movements seeded plus 2 org-custom rows in the pro org.
  • A second member (“Member Niv”) for the cross-athlete fork-isolation tests.
  • Switch language between English (LTR), Hebrew (RTL), and Russian for the i18n pass.
  • E2E auth is storageState-cached; use usePersona() rather than re-signing in.

Test scenarios

Golden path (happy path)

  1. Create structured workout (coach)

    • Trigger: Casey opens /dashboard/workouts/new/builder, enters title “Fran”, picks scoring time, adds a section “Main” with 2 movements (Thrusters + Pull-ups), saves.
    • Expected: workout appears in the library list at /dashboard/workouts with displayName “Fran”, two movements visible in detail.
    • Verify in DB: one row in workouts (is_snapshot=false), one workout_sections, two workout_movements, embedding column will populate within ~30s.
  2. List library workouts

    • Trigger: GET /organizations/:orgId/workouts.
    • Expected: returns only is_snapshot=false, deleted_at IS NULL rows.
  3. Filter library by free text

    • Trigger: search “fran” from the library list.
    • Expected: case-insensitive match on title and description.
  4. Open existing workout for edit

    • Trigger: Click into Fran at /dashboard/workouts/[id], switch to edit at /dashboard/workouts/[id]/edit.
    • Expected: builder loads with all sections, movements, prescriptions, labels (A, B), supersets restored.
  5. Add a prescription

    • Trigger: For movement A (Thrusters), set sets=3, reps={kind:'fixed',value:21}, load={kind:'absolute',value:42.5,unit:'kg'}. Save.
    • Expected: workout_movements.prescription JSONB matches. Detail view re-renders with the prescription text.
  6. Coach pins a comment on a movement

    • Trigger: From a workout detail, open the movement comment thread, post “watch your knees”.
    • Expected: comment visible to Casey, Mia, and Niv. Member Mia replies — Casey gets a push notification (category: 'newComment').
  7. Assign workout and observe per-day snapshot is a pointer

    • Trigger: Casey assigns Fran to Mia for today via /dashboard/programs/.../week/.../members cell click. Inspect workout_assignments.
    • Expected: snapshot_workout_id === workout_id (pointer state). No new workout row.
  8. Lazy-fork on coach edit-from-cell

    • Trigger: Casey opens Mia’s Fran cell, edits the Thruster load to 40kg. Save.
    • Expected: a new workouts row exists with is_snapshot=true, forked_from_id = library Fran id. Mia’s assignment snapshot_workout_id now points at the new id. Library Fran is unchanged. Comments from step 6 are visible on both the library row and the snapshot (duplicated).
  9. Niv’s snapshot still shows the library prescription

    • Trigger: Niv is also assigned Fran for today (pointer state, never edited). Niv views the workout.
    • Expected: Niv sees 42.5kg, not Mia’s 40kg.
  10. Member logs a result, assignment auto-completes

    • Trigger: Mia logs a result against today’s Fran assignment.
    • Expected: workout_results.snapshot_workout_id = Mia’s snapshot id; library_workout_id = library Fran id; workout_assignments.status flips assigned → completed, completed_at set.

Edge cases

  1. Create with a cross-org exerciseId

    • Trigger: POST /workouts with a movement pointing at an exercise from a different org.
    • Expected: 400 “One or more exercises not found in this organization or the canonical library”.
  2. Create with a canonical (global) exerciseId

    • Trigger: POST /workouts referencing a canonical exercise.
    • Expected: 200; canonical row is accepted.
  3. Edit movement after fork using the library-row movement id

    • Trigger: Mia’s assignment is forked; Casey’s frontend still holds a library-row movementId. She PATCHes with ?assignmentId=<Mia's>.
    • Expected: server uses (section.sortOrder, movement.sortOrder) to find the snapshot’s mirror movement; update lands on the snapshot. No 404.
  4. Concurrent edits to a not-yet-forked assignment

    • Trigger: Two browser tabs send PATCH …prescription?assignmentId=X for the same assignment simultaneously.
    • Expected: only one snapshot row created (verified by SELECT count(*) FROM workouts WHERE forked_from_id = LibA AND is_snapshot=true); both edits applied to that one snapshot.
  5. Delete library workout that has snapshots

    • Trigger: DELETE /workouts/:id for a library Fran that has 5 athlete snapshots.
    • Expected: library row soft-deleted; snapshots remain. Past results and assignments still load. The library row no longer appears in GET /workouts.
  6. Attempt to delete a snapshot directly

    • Trigger: DELETE /workouts/:snapshotId.
    • Expected: 400 “Cannot delete a snapshot workout — it is referenced by historical results”.
  7. setSections with non-empty sections on a Lite-tier org

    • Trigger: Lior on the lite org calls PUT /workouts/:id/sections with one section.
    • Expected: 403 mentioning workout_builder feature.
  8. Flip an existing freeform workout to structured on Lite tier

    • Trigger: Lior PATCH /workouts/:id with { mode: 'structured' }.
    • Expected: 403.
  9. Create freeform workout on Lite tier

    • Trigger: Lior POST /workouts with mode='freeform', no sections.
    • Expected: 200; row created.
  10. Snapshot rows excluded from library list

    • Trigger: After several lazy-forks, GET /workouts from any persona.
    • Expected: only library rows returned. Verify in UI: snapshot rows never appear in /dashboard/workouts.
  11. Movement comment with attachment

    • Trigger: Casey posts a comment with one image attachment.
    • Expected: comment lists include attachments[] with presigned url + thumbUrl. Visiting Mia’s account, same comment shows the same attachments (after snapshot fork they are duplicated).
  12. Comment without body or attachments

    • Trigger: Post empty comment.
    • Expected: 400 “Comment must have body or attachments”.

Negative / security

  1. Member tries to create a workout

    • Trigger: Mia calls POST /workouts.
    • Expected: 403 “Insufficient permissions”.
  2. Member tries to update a workout’s prescription

    • Trigger: Mia calls PATCH .../movements/:id/prescription.
    • Expected: 403.
  3. Cross-org workout access

    • Trigger: Casey (org A) calls GET /organizations/<orgB>/workouts/:id for a workout in org B.
    • Expected: 403/404 — requireMembership rejects. No data leaks.
  4. Update movement prescription with a movement id from a different workout

    • Trigger: PATCH /workouts/:wId/movements/:mId/prescription where mId lives in a different workout in the same org.
    • Expected: 404 “Movement not found” (org check + section→workout join blocks it).
  5. Cross-org movement id (no assignmentId)

    • Trigger: same as 26 but mId from org B.
    • Expected: 404.
  6. Edit-from-cell against a deleted assignment

    • Trigger: Coach edits Mia’s prescription with ?assignmentId=X where X is soft-deleted.
    • Expected: 400 “Assignment has been deleted”.
  7. Edit-from-cell against a rest/note assignment

    • Trigger: Coach attempts the same against a kind='rest' assignment.
    • Expected: 400 “Cannot fork a non-workout assignment”.
  8. Program ID from another org

    • Trigger: POST /workouts with programId belonging to org B.
    • Expected: 400 “Program not found in this organization”.

Cross-persona

  1. Coach creates Fran → Member sees Fran in detail

    • Trigger: After step 1, assign to Mia. Mia opens whiteboard.
    • Expected: Fran with movement letters, prescriptions, exercise names + video URLs visible.
  2. Coach updates library Fran AFTER Mia is assigned (pointer state)

    • Trigger: Coach edits library Fran. Mia’s assignment was never edited from cell, so she’s still pointing at the library row.
    • Expected: Mia sees the updated prescription (pointer follows the library). This is intentional pre-fork behavior.
  3. Coach updates library Fran AFTER Mia’s snapshot is forked

    • Trigger: After step 8, coach updates the library Fran prescription.
    • Expected: Mia’s snapshot is unchanged. The change does not leak.

i18n

  1. Builder UI in Hebrew (RTL)

    • Trigger: Switch language to he. Open the builder.
    • Expected: All section labels, prescription panel labels, ribbon actions, save/cancel buttons localized. Layout flows RTL — section action buttons appear on the left, drag handles on the right.
  2. Builder UI in Russian

    • Trigger: Switch to ru. Open the builder.
    • Expected: All strings localized; “Workout Builder” feature gate message and Lite-upgrade prompt translated.
  3. Comment posting in Hebrew

    • Trigger: Casey posts a Hebrew comment with mixed Hebrew + emoji.
    • Expected: stored and rendered correctly, RTL alignment.

What “broken” looks like

  • A snapshot row appears in the library list at /dashboard/workouts.
  • Editing one athlete’s prescription mutates the library row (other athletes’ upcoming days change).
  • A coach upgrade to a structured workout slips through on a Lite-tier org and the coach then can’t open the builder (the FIT-bug origin of requireWorkoutBuilder).
  • A snapshot row is soft-deleted, then loading a historical result 404s because the joined workout is null.
  • After a fork, a follow-up edit goes to the library row (frontend is holding library movement ids and the mapMovementToSnapshot mapping silently fails).
  • Comment posted on library Fran does NOT appear when Mia views her forked snapshot (the deep-copy didn’t carry comments).
  • Embedding queue blocking the create response (it should be best-effort fire-and-forget).
  • Two browser tabs each create their own snapshot row for the same assignment (the FOR UPDATE row lock failed).