Workouts — QA plan
Pre-requisites
- Local stack running:
docker compose up -d, thenpnpm dev(API on 3001, web on 3000). - Seeded org with platform tier
proorelite(for structured-mode tests) and a second org on tierlite(for the tier-gate negative test). - Personas seeded:
- Coach Casey —
coachrole in theproorg. - Owner Olga —
ownerrole in theproorg. - Member Mia —
memberrole in theproorg. - Coach Lite Lior —
coachrole in theliteorg.
- Coach Casey —
- Exercise library: at least 20 canonical movements seeded plus 2 org-custom rows in the
proorg. - 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; useusePersona()rather than re-signing in.
Test scenarios
Golden path (happy path)
-
Create structured workout (coach)
- Trigger: Casey opens
/dashboard/workouts/new/builder, enters title “Fran”, picks scoringtime, adds a section “Main” with 2 movements (Thrusters + Pull-ups), saves. - Expected: workout appears in the library list at
/dashboard/workoutswith displayName “Fran”, two movements visible in detail. - Verify in DB: one row in
workouts(is_snapshot=false), oneworkout_sections, twoworkout_movements,embeddingcolumn will populate within ~30s.
- Trigger: Casey opens
-
List library workouts
- Trigger:
GET /organizations/:orgId/workouts. - Expected: returns only
is_snapshot=false,deleted_at IS NULLrows.
- Trigger:
-
Filter library by free text
- Trigger: search “fran” from the library list.
- Expected: case-insensitive match on title and description.
-
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.
- Trigger: Click into Fran at
-
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.prescriptionJSONB matches. Detail view re-renders with the prescription text.
- Trigger: For movement A (Thrusters), set
-
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').
-
Assign workout and observe per-day snapshot is a pointer
- Trigger: Casey assigns Fran to Mia for today via
/dashboard/programs/.../week/.../memberscell click. Inspectworkout_assignments. - Expected:
snapshot_workout_id === workout_id(pointer state). No new workout row.
- Trigger: Casey assigns Fran to Mia for today via
-
Lazy-fork on coach edit-from-cell
- Trigger: Casey opens Mia’s Fran cell, edits the Thruster load to
40kg. Save. - Expected: a new
workoutsrow exists withis_snapshot=true,forked_from_id= library Fran id. Mia’s assignmentsnapshot_workout_idnow points at the new id. Library Fran is unchanged. Comments from step 6 are visible on both the library row and the snapshot (duplicated).
- Trigger: Casey opens Mia’s Fran cell, edits the Thruster load to
-
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’s40kg.
-
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.statusflipsassigned → completed,completed_atset.
Edge cases
-
Create with a cross-org exerciseId
- Trigger:
POST /workoutswith 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”.
- Trigger:
-
Create with a canonical (global) exerciseId
- Trigger:
POST /workoutsreferencing a canonical exercise. - Expected: 200; canonical row is accepted.
- Trigger:
-
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.
- Trigger: Mia’s assignment is forked; Casey’s frontend still holds a library-row
-
Concurrent edits to a not-yet-forked assignment
- Trigger: Two browser tabs send
PATCH …prescription?assignmentId=Xfor 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.
- Trigger: Two browser tabs send
-
Delete library workout that has snapshots
- Trigger:
DELETE /workouts/:idfor 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.
- Trigger:
-
Attempt to delete a snapshot directly
- Trigger:
DELETE /workouts/:snapshotId. - Expected: 400 “Cannot delete a snapshot workout — it is referenced by historical results”.
- Trigger:
-
setSectionswith non-empty sections on a Lite-tier org- Trigger: Lior on the
liteorg callsPUT /workouts/:id/sectionswith one section. - Expected: 403 mentioning
workout_builderfeature.
- Trigger: Lior on the
-
Flip an existing freeform workout to structured on Lite tier
- Trigger: Lior
PATCH /workouts/:idwith{ mode: 'structured' }. - Expected: 403.
- Trigger: Lior
-
Create freeform workout on Lite tier
- Trigger: Lior
POST /workoutswithmode='freeform', no sections. - Expected: 200; row created.
- Trigger: Lior
-
Snapshot rows excluded from library list
- Trigger: After several lazy-forks,
GET /workoutsfrom any persona. - Expected: only library rows returned. Verify in UI: snapshot rows never appear in
/dashboard/workouts.
- Trigger: After several lazy-forks,
-
Movement comment with attachment
- Trigger: Casey posts a comment with one image attachment.
- Expected: comment lists include
attachments[]with presignedurl+thumbUrl. Visiting Mia’s account, same comment shows the same attachments (after snapshot fork they are duplicated).
-
Comment without body or attachments
- Trigger: Post empty comment.
- Expected: 400 “Comment must have body or attachments”.
Negative / security
-
Member tries to create a workout
- Trigger: Mia calls
POST /workouts. - Expected: 403 “Insufficient permissions”.
- Trigger: Mia calls
-
Member tries to update a workout’s prescription
- Trigger: Mia calls
PATCH .../movements/:id/prescription. - Expected: 403.
- Trigger: Mia calls
-
Cross-org workout access
- Trigger: Casey (org A) calls
GET /organizations/<orgB>/workouts/:idfor a workout in org B. - Expected: 403/404 —
requireMembershiprejects. No data leaks.
- Trigger: Casey (org A) calls
-
Update movement prescription with a movement id from a different workout
- Trigger:
PATCH /workouts/:wId/movements/:mId/prescriptionwheremIdlives in a different workout in the same org. - Expected: 404 “Movement not found” (org check + section→workout join blocks it).
- Trigger:
-
Cross-org movement id (no assignmentId)
- Trigger: same as 26 but
mIdfrom org B. - Expected: 404.
- Trigger: same as 26 but
-
Edit-from-cell against a deleted assignment
- Trigger: Coach edits Mia’s prescription with
?assignmentId=Xwhere X is soft-deleted. - Expected: 400 “Assignment has been deleted”.
- Trigger: Coach edits Mia’s prescription with
-
Edit-from-cell against a
rest/noteassignment- Trigger: Coach attempts the same against a
kind='rest'assignment. - Expected: 400 “Cannot fork a non-workout assignment”.
- Trigger: Coach attempts the same against a
-
Program ID from another org
- Trigger:
POST /workoutswithprogramIdbelonging to org B. - Expected: 400 “Program not found in this organization”.
- Trigger:
Cross-persona
-
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.
-
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.
-
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
-
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.
- Trigger: Switch language to
-
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.
- Trigger: Switch to
-
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
mapMovementToSnapshotmapping 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).