Workout assignments — QA plan
Pre-requisites
- Pro-tier org with:
- 3 members enrolled in a coaching-mode program (Mia, Niv, Liat).
- 1 feed-mode program with no enrollments (broadcast surface).
- 1 schedule-mode program with class type “WOD” and a default location.
- Library workouts: “Fran”, “Back Squat 5x5”, “Easy Row 20min”.
- Personas: Coach Casey, Owner Olga, Members Mia/Niv/Liat.
- Org timezone set to
Asia/Jerusalem.
Test scenarios
Personal mode
-
Assign workout to 3 athletes
- Trigger:
POST /assignments/personal{ workoutId:<fran>, athleteIds:[mia,niv,liat], date:'2026-06-03', drip:'now' }. - Expected: 3 rows inserted. All
published=true. Each athlete gets a push (category: workoutAssigned). All in pointer state.
- Trigger:
-
Assign rest day
- Trigger: same endpoint with
kind:'rest', workoutId:null. - Expected: 3 rows with
kind='rest', no pointers, nonote. No push fires.
- Trigger: same endpoint with
-
Assign coach note
- Trigger:
kind:'note', note:'no caffeine before today'. - Expected: 3 rows with note text. CHECK accepts.
- Trigger:
-
Note kind with empty text
- Trigger:
kind:'note', note:' '(whitespace). - Expected: 400 “note text is required when kind=‘note’”.
- Trigger:
-
Workout kind with no workoutId
- Trigger:
kind:'workout', workoutId:null. - Expected: 400.
- Trigger:
-
Rest kind with stray workoutId
- Trigger:
kind:'rest', workoutId:<X>. - Expected: 400.
- Trigger:
-
Cross-org workoutId
- Trigger: workoutId from a different org.
- Expected: 400 from
validateWorkoutBelongsToOrg.
-
Morning-of drip
- Trigger:
drip:'morning_of'. - Expected: rows insert with
published=false,publish_at = date @ 05:00 UTC. No push at insert time. (Publishing requires the morning-of worker — TODO: verify worker job is registered.)
- Trigger:
Feed mode
-
Publish to feed-mode track
- Trigger:
POST /assignments/feed{ trackId:<feedProgramId>, workoutId, publishAt:'2026-06-03T06:00:00+03:00', note:'today is gnarly' }. - Expected:
workout_feed_postsrow inserted withpublish_atstored in UTC;snapshot_workout_id = workout_id(pointer state).
- Trigger:
-
Feed publish to non-feed program
- Trigger: same with
trackIdpointing at the coaching program. - Expected: 400 “Track must be a feed-mode program”.
- Trigger: same with
Schedule mode
-
Assign to class sessions
- Trigger:
POST /assignments/schedule{ workoutId, classTypeId:<wod>, slots:[{ startAt:'2026-06-04T07:00:00+03:00', capacity:12 }] }. - Expected: 1
class_sessionsrow + 1class_session_workoutslink row.
- Trigger:
-
Schedule assign without classTypeId
- Trigger: omit
classTypeId. - Expected: 400.
- Trigger: omit
Member reads
-
my-week- Trigger: Mia
GET /assignments/my-week?weekStart=2026-06-01. - Expected: all her published assignments Mon-Sun. Drafts excluded. Each row carries the full snapshot workout payload (sections + movements + exercises).
- Trigger: Mia
-
today- Trigger: Mia
GET /assignments/today?date=2026-06-03. - Expected: her published rows for that day.
- Trigger: Mia
-
/:id(own row, member)- Trigger: Mia
GET /assignments/<her_id>. - Expected: 200 with full payload.
- Trigger: Mia
-
/:id(other’s row, member)- Trigger: Mia
GET /assignments/<niv_id>. - Expected: 404 (no existence leak).
- Trigger: Mia
-
/:id(other’s row, coach)- Trigger: Casey
GET /assignments/<niv_id>. - Expected: 200.
- Trigger: Casey
Lazy-fork lifecycle
-
First fork on edit
- Trigger: After step 1, Casey
PATCH /workouts/<fran>/movements/<m>/prescription?assignmentId=<mia_assignment>. - Expected: new
workoutsrow withis_snapshot=true,forked_from_id=<fran>. Mia’ssnapshot_workout_idupdated. Library Fran unchanged.
- Trigger: After step 1, Casey
-
Concurrent forks
- Trigger: two browser tabs both PATCH with the same
assignmentIdsimultaneously. - Expected: only one snapshot row created. Both edits land on the same snapshot.
- Trigger: two browser tabs both PATCH with the same
-
Edit on already-forked assignment
- Trigger: After step 18, another PATCH with the same assignmentId.
- Expected:
didFork=false, edit applies to existing snapshot.
-
Fork on rest/note kind
- Trigger: edit-from-cell against a
kind='rest'assignment. - Expected: 400 “Cannot fork a non-workout assignment”.
- Trigger: edit-from-cell against a
-
Comments duplicated on fork
- Trigger: Coach posts comment on a library Fran movement. THEN edits Mia’s prescription, forking the snapshot.
- Expected: Mia’s snapshot movement carries the duplicated comment (same body, same author, reply threading intact).
Completion & status
-
Complete via result
- Trigger: Mia logs a result against
<mia_assignment>. - Expected: assignment
status='completed',completed_atset. (Result anchors to snapshot.)
- Trigger: Mia logs a result against
-
Complete via endpoint
- Trigger: Mia
POST /:id/complete. - Expected: status→completed.
- Trigger: Mia
-
Complete on already-completed
- Trigger: repeat #24.
- Expected: idempotent —
WHERE status='assigned'filters to a no-op. TODO: verify response code (200 vs 304).
-
Skip
- Trigger: Mia
POST /:id/skip. - Expected: status→skipped.
- Trigger: Mia
Bulk operations
-
Bulk preview
- Trigger:
POST /bulk-preview{ filter: { programId:<coaching>, dateFrom:'2026-06-01', dateTo:'2026-06-07', published:false } }. - Expected:
{ matched: N, samples: [10 rows with id/date/kind/workoutName/userName] }. Confirm count matches manual SQL.
- Trigger:
-
Bulk delete
- Trigger: same filter,
POST /bulk-delete. - Expected: matched rows soft-deleted. One
audit_logsrow with thebatch_idreturned. Results referencing those assignments still load via FK.
- Trigger: same filter,
-
Bulk publish
- Trigger:
POST /bulk-publishwithfilter.published=true(service forces it back to false). - Expected: only
published=falserows match. Service ignores caller’struevalue. After mutation, matched rows arepublished=true.
- Trigger:
-
Bulk preview with ad-hoc personal rows
- Trigger: Mia is enrolled in program P. She also has an ad-hoc personal assignment with
programId=nullon Tuesday. Bulk preview byprogramId=P. - Expected: the ad-hoc row is included (FIT-201 fix — intersect with enrollments, not assignment.programId).
- Trigger: Mia is enrolled in program P. She also has an ad-hoc personal assignment with
-
Bulk with restricted userIds
- Trigger:
filter.userIds=[<niv>]where Niv is NOT enrolled infilter.programId. - Expected: 0 matched (intersection empty).
- Trigger:
Coach overview
-
KPIs
- Trigger:
GET /assignments/coach-overview. - Expected:
{ workoutsDueToday, sevenDayCompliancePct, needsAttentionCount, activeMembersCount }reflect the 7-day window.
- Trigger:
-
Roster sparkline
- Trigger: same.
- Expected: each roster entry has a 7-element
sparkline(oldest→newest),nextDueDate,lastCompletedAt, status (on_track/at_risk/overdue/quiet). Order: overdue → at_risk → quiet → on_track.
-
Compliance thresholds
- Trigger: Set Mia to 4/5 complete (80%), Niv to 2/5 (40%), Liat to 0/0 (quiet).
- Expected: Mia=on_track, Niv=overdue, Liat=quiet.
Permissions
-
Member assignment endpoints (create/update/delete)
- Trigger: Mia tries
POST /assignments/personal. - Expected: 403 from role gate.
- Trigger: Mia tries
-
Cross-org assignment view
- Trigger: Coach Bob (org B) calls
GET /organizations/<orgA>/assignments/.... - Expected: 403 from
requireMembership.
- Trigger: Coach Bob (org B) calls
i18n
-
My week in Hebrew
- Trigger: Mia switches to
he. - Expected: all assignment labels, kind chips (workout/rest/note), completion buttons translated; RTL layout.
- Trigger: Mia switches to
-
Coach overview in Russian
- Trigger: Olga switches to
ru. - Expected: all KPI labels, status chips, sparkline tooltips translated.
- Trigger: Olga switches to
What “broken” looks like
- Concurrent forks create two snapshot rows for the same assignment (FOR UPDATE missing).
- A member reads another member’s assignment by id (404 → 200 leak).
- Bulk preview misses ad-hoc personal rows on enrolled members (FIT-201 regression).
- Bulk publish flips already-published rows (force-false override broken).
- Coach edit on a library workout (no
?assignmentId) silently mutates a snapshot (resolveEditTargetbroken). completesucceeds on a deleted assignment (isNull(deletedAt)filter missing on the UPDATE).- Push fires for
published=falserows on morning-of drip insert (theif (r.published)gate is gone). - Sparkline status order on roster is wrong (overdue not at the top).
assignFeedfor the same(programId, workoutId, publishAt)accidentally creates duplicate posts (no uniqueness, intentional — but verify the UI deduplicates if needed).