Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkout AssignmentsWorkout assignments — QA plan

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

  1. 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.
  2. Assign rest day

    • Trigger: same endpoint with kind:'rest', workoutId:null.
    • Expected: 3 rows with kind='rest', no pointers, no note. No push fires.
  3. Assign coach note

    • Trigger: kind:'note', note:'no caffeine before today'.
    • Expected: 3 rows with note text. CHECK accepts.
  4. Note kind with empty text

    • Trigger: kind:'note', note:' ' (whitespace).
    • Expected: 400 “note text is required when kind=‘note’”.
  5. Workout kind with no workoutId

    • Trigger: kind:'workout', workoutId:null.
    • Expected: 400.
  6. Rest kind with stray workoutId

    • Trigger: kind:'rest', workoutId:<X>.
    • Expected: 400.
  7. Cross-org workoutId

    • Trigger: workoutId from a different org.
    • Expected: 400 from validateWorkoutBelongsToOrg.
  8. 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.)

Feed mode

  1. 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_posts row inserted with publish_at stored in UTC; snapshot_workout_id = workout_id (pointer state).
  2. Feed publish to non-feed program

    • Trigger: same with trackId pointing at the coaching program.
    • Expected: 400 “Track must be a feed-mode program”.

Schedule mode

  1. 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_sessions row + 1 class_session_workouts link row.
  2. Schedule assign without classTypeId

    • Trigger: omit classTypeId.
    • Expected: 400.

Member reads

  1. 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).
  2. today

    • Trigger: Mia GET /assignments/today?date=2026-06-03.
    • Expected: her published rows for that day.
  3. /:id (own row, member)

    • Trigger: Mia GET /assignments/<her_id>.
    • Expected: 200 with full payload.
  4. /:id (other’s row, member)

    • Trigger: Mia GET /assignments/<niv_id>.
    • Expected: 404 (no existence leak).
  5. /:id (other’s row, coach)

    • Trigger: Casey GET /assignments/<niv_id>.
    • Expected: 200.

Lazy-fork lifecycle

  1. First fork on edit

    • Trigger: After step 1, Casey PATCH /workouts/<fran>/movements/<m>/prescription?assignmentId=<mia_assignment>.
    • Expected: new workouts row with is_snapshot=true, forked_from_id=<fran>. Mia’s snapshot_workout_id updated. Library Fran unchanged.
  2. Concurrent forks

    • Trigger: two browser tabs both PATCH with the same assignmentId simultaneously.
    • Expected: only one snapshot row created. Both edits land on the same snapshot.
  3. Edit on already-forked assignment

    • Trigger: After step 18, another PATCH with the same assignmentId.
    • Expected: didFork=false, edit applies to existing snapshot.
  4. Fork on rest/note kind

    • Trigger: edit-from-cell against a kind='rest' assignment.
    • Expected: 400 “Cannot fork a non-workout assignment”.
  5. 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

  1. Complete via result

    • Trigger: Mia logs a result against <mia_assignment>.
    • Expected: assignment status='completed', completed_at set. (Result anchors to snapshot.)
  2. Complete via endpoint

    • Trigger: Mia POST /:id/complete.
    • Expected: status→completed.
  3. Complete on already-completed

    • Trigger: repeat #24.
    • Expected: idempotent — WHERE status='assigned' filters to a no-op. TODO: verify response code (200 vs 304).
  4. Skip

    • Trigger: Mia POST /:id/skip.
    • Expected: status→skipped.

Bulk operations

  1. 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.
  2. Bulk delete

    • Trigger: same filter, POST /bulk-delete.
    • Expected: matched rows soft-deleted. One audit_logs row with the batch_id returned. Results referencing those assignments still load via FK.
  3. Bulk publish

    • Trigger: POST /bulk-publish with filter.published=true (service forces it back to false).
    • Expected: only published=false rows match. Service ignores caller’s true value. After mutation, matched rows are published=true.
  4. Bulk preview with ad-hoc personal rows

    • Trigger: Mia is enrolled in program P. She also has an ad-hoc personal assignment with programId=null on Tuesday. Bulk preview by programId=P.
    • Expected: the ad-hoc row is included (FIT-201 fix — intersect with enrollments, not assignment.programId).
  5. Bulk with restricted userIds

    • Trigger: filter.userIds=[<niv>] where Niv is NOT enrolled in filter.programId.
    • Expected: 0 matched (intersection empty).

Coach overview

  1. KPIs

    • Trigger: GET /assignments/coach-overview.
    • Expected: { workoutsDueToday, sevenDayCompliancePct, needsAttentionCount, activeMembersCount } reflect the 7-day window.
  2. 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.
  3. 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

  1. Member assignment endpoints (create/update/delete)

    • Trigger: Mia tries POST /assignments/personal.
    • Expected: 403 from role gate.
  2. Cross-org assignment view

    • Trigger: Coach Bob (org B) calls GET /organizations/<orgA>/assignments/....
    • Expected: 403 from requireMembership.

i18n

  1. My week in Hebrew

    • Trigger: Mia switches to he.
    • Expected: all assignment labels, kind chips (workout/rest/note), completion buttons translated; RTL layout.
  2. Coach overview in Russian

    • Trigger: Olga switches to ru.
    • Expected: all KPI labels, status chips, sparkline tooltips translated.

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 (resolveEditTarget broken).
  • complete succeeds on a deleted assignment (isNull(deletedAt) filter missing on the UPDATE).
  • Push fires for published=false rows on morning-of drip insert (the if (r.published) gate is gone).
  • Sparkline status order on roster is wrong (overdue not at the top).
  • assignFeed for the same (programId, workoutId, publishAt) accidentally creates duplicate posts (no uniqueness, intentional — but verify the UI deduplicates if needed).