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 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 = 06:00 org time on the date. No push at insert time. Within a minute of publish_at, ProgramSessionsService.publishDue publishes them (session included), clears publish_at, and pushes once; a later tick changes nothing. Hiding the session before then (published:false) clears publish_at and it is never released.

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 16, 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 #22.
    • 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 excluded. Only assignments whose own programId is P match, even when Mia also belongs to another program.
  5. Bulk with restricted userIds

    • Trigger: filter.userIds=[<niv>] where Niv is NOT enrolled in filter.programId.
    • Expected: 0 matched (intersection empty).
  6. 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_logs row is written.

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.

  1. 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_logs row is written.

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.

  1. Publish reports rows written
    • Trigger: PATCH /publish with three ids, only one of which the asserted programId owns.
    • Expected: response count is 1, not 3, and the agent narrates one published row.

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, 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 (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).