Workout assignments — QA plan
Pre-requisites
- Pro-tier org with:
- 3 members enrolled in a coaching-mode program (Mia, Niv, Liat).
- 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= 06:00 org time on the date. No push at insert time. Within a minute ofpublish_at,ProgramSessionsService.publishDuepublishes them (session included), clearspublish_at, and pushes once; a later tick changes nothing. Hiding the session before then (published:false) clearspublish_atand it is never released.
- Trigger:
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 16, 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 #22.
- 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 excluded. Only assignments whose own
programIdis P match, even when Mia also belongs to another program.
- 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:
-
Program-day replacement
- Trigger:
POST /program-day/replace{ programId:<coaching>, workoutId:<back-squat>, date:'2026-06-03' }, where two active enrollees have existing program-bound rows and a third-party program plus ad-hoc row use the same date. - Expected: prior rows for the target program/date are soft-deleted; exactly one published replacement row per active enrollee points at the new release; the other program and ad-hoc rows remain unchanged. A transaction failure leaves the old target rows intact. One
audit_logsrow is written.
- Trigger:
30b. Program-day replacement over completed work - Trigger: same call, where one of the two enrollees already completed that day and logged a result. - Expected: the completed row is NOT deleted and its owner gets no replacement row and no push; the other enrollee’s row is replaced. Compliance and completion figures do not drop.
30c. Program-day replacement for a past date
- Trigger: same call with date in the past for the org’s timezone.
- Expected: rows are replaced, but no “new workout” push is sent.
30d. Concurrent program-day replacement
- Trigger: fire two replaces at once, once on an empty day and once on a populated one (double-click, two coaches, or an agent retry).
- Expected: each enrollee ends with exactly one row for the day, not two, and the second replace applies its own workout rather than mistaking the first one’s fresh rows for completed work. Guarded by program-day-concurrency.int.spec.ts against a real Postgres.
- Program-day removal
- Trigger:
POST /program-day/remove{ programId:<coaching>, date:'2026-06-03' }as a coach, with target-program, other-program and ad-hoc rows present. - Expected: every non-deleted target-program row is soft-deleted, completed ones included; other-program and ad-hoc rows remain unchanged. The UI requires confirmation and uses this coach-authorized endpoint rather than owner/admin bulk delete. One
audit_logsrow is written.
- Trigger:
31b. Program-day confirmation count matches the write - Trigger: a day holding rows for a member who has since been unenrolled, plus one completed row. Compare the agent confirmation card and the dashboard “Remove day” dialog to what the approved call touches. - Expected: removal is previewed with every row on the day, including the unenrolled member’s; replacement is previewed with the rows it clears plus the rows it issues. The dashboard dialog shows the server’s count, not the number of members drawn in the grid.
31c. Replacing an empty program day from Spotter
- Trigger: replace_program_day on a date with no assignments, in a program with 20 active enrollees.
- Expected: the confirmation card reads 20, not 0 — approving publishes a workout to the whole roster and notifies them.
- Publish reports rows written
- Trigger:
PATCH /publishwith three ids, only one of which the assertedprogramIdowns. - Expected: response
countis 1, not 3, and the agent narrates one published row.
- 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, grid, comments or statistics include another program’s or unscoped ad-hoc assignments merely because the athlete is enrolled.
- 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).