Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesProgram TemplatesProgram templates — QA plan

Program templates — QA plan

Pre-requisites

  • Local stack running, pro-tier org with members.
  • Seeded:
    • 5+ library workouts (is_snapshot=false) in the org.
    • 1 coaching-mode program with 3 active members.
    • 1 feed-mode program (programs.deliveryMode='feed').
    • 1 schedule-mode program with a class type (“WOD”), one location, and a coach membership.
    • 3 athletes with empty calendars for the next 4 weeks.
  • Personas: Coach Casey, Owner Olga, Member Mia (any one of the 3 athletes works for verification).
  • Org timezone set to a non-UTC value (e.g. Asia/Jerusalem) to validate timezone math.

Test scenarios

Golden path — coaching template

  1. Create header

    • Trigger: POST /program-templates with { name:'8wk Strength', deliveryMode:'coaching', durationWeeks:8 }.
    • Expected: 201 with empty cells array.
  2. Fill cells via grid

    • Trigger: For week 1 day 1, drop in workout “Back Squat 5x5”; week 1 day 3, “Bench 5x5”; week 1 day 5, rest; week 2 day 1, note “deload week — drop to 80%”.
    • Expected: POST /:id/workouts succeeds; DB has 4 program_template_workouts rows. CHECKs accept each cell.
  3. Preview against cohort

    • Trigger: POST /:id/preview with { mode:'coaching', startDate:'2026-06-01', userIds:[<mia>,<niv>,<lior>], conflictMode:'skip' }.
    • Expected: preview returns expected created count (cells × athletes), skipped count for existing conflicts (should be 0 on a clean calendar), no DB writes.
  4. Apply

    • Trigger: POST /:id/apply with the same body.
    • Expected: created count matches preview. New workout_assignments rows in DB: kind=workout with both pointers set; kind=rest with both pointers null; kind=note with note text set. All in pointer state (snapshot_workout_id = workout_id for workout kind).
  5. Verify member view

    • Trigger: Mia visits whiteboard on 2026-06-01.
    • Expected: assigned Back Squat 5x5 visible, but only if published=true — note that applyToCoaching writes published=false, so confirm publishing flow.

Golden path — feed template

  1. Create feed template

    • Trigger: POST /program-templates { name:'WOD Feed', deliveryMode:'feed', durationWeeks:4 }.
    • Expected: 201.
  2. Fill 7-day cycle

    • Trigger: Cells for every dayOffset 1..7 of week 1.
    • Expected: 7 cells.
  3. Apply to feed program

    • Trigger: POST /:id/apply { mode:'feed', programId:<feedProgramId>, startDate:'2026-06-01', publishTimeOfDay:'06:00' }.
    • Expected: 28 workout_feed_posts (4 weeks × 7 days), each with publishAt = local 06:00 Asia/Jerusalem → UTC.
  4. Verify timezone math

    • Trigger: Inspect workout_feed_posts.publish_at for the first row.
    • Expected: For Asia/Jerusalem summer date, UTC = 2026-06-01T03:00:00Z (DST applied via date-fns-tz).

Golden path — schedule template

  1. Create schedule template

    • Trigger: POST /program-templates { name:'Mon/Wed/Fri 7am', deliveryMode:'schedule', durationWeeks:4, targetClassTypeId, daysOfWeek:['monday','wednesday','friday'], startTimes:['07:00'], defaultLocationId, defaultCoachMembershipId }.
    • Expected: 201.
  2. Add workout to Wednesday week 1

    • Trigger: Cell at week=1, dayOffset=3 (Wed) with workout.
    • Expected: 1 cell row.
  3. Apply

    • Trigger: POST /:id/apply { mode:'schedule', startDate:'2026-06-01' } (Monday).
    • Expected: 12 class_sessions (4 wks × 3 active days × 1 start time), all with status='draft', startsAt computed in org timezone → UTC. 1 daily_programming row for the Wednesday workout.
  4. Mode-mismatch apply

    • Trigger: POST /:id/apply on a schedule template with { mode:'feed', ... }.
    • Expected: 400 from assertModeMatches.

Save-as-template (from-history)

  1. Save coaching history

    • Trigger: POST /from-history { source:'coaching', sourceId:<userId>, startDate, endDate, name:'last 4 weeks' }.
    • Expected: New template created; cells reflect the user’s last 4 weeks of workout_assignments.
  2. Save schedule history with recurrence inference

    • Trigger: POST /from-history { source:'schedule', sourceId:<classTypeId>, startDate, endDate }.
    • Expected: New schedule-mode template with daysOfWeek + startTimes derived from the unique weekdays/times of the source sessions. TODO: verify timezone handling — current implementation reads UTC weekday/hour.
  3. From-history with empty range

    • Trigger: Range with no data.
    • Expected: 400 “No data in the selected range”.

Edge cases

  1. Cell weekNumber > durationWeeks

    • Trigger: durationWeeks=4, cell with weekNumber=5.
    • Expected: 400.
  2. Cell with cross-org workout

    • Trigger: workoutId from a different org.
    • Expected: 400 from assertLibraryWorkoutInOrg.
  3. Cell with snapshot workout id

    • Trigger: workoutId pointing at a is_snapshot=true row.
    • Expected: 400 (snapshot is excluded from “library” check).
  4. Note cell missing text

    • Trigger: kind:'note', coachNote:null.
    • Expected: 400 “coachNote required when kind=‘note’”.
  5. Workout cell with stray coachNote

    • Trigger: kind:'workout', workoutId:X, coachNote:'foo'.
    • Expected: API accepts (only workoutId is required), but DB CHECK program_template_workouts_kind_payload_chk rejects (coachNote must be null). Verify which error surfaces and consider if API should validate first. TODO: verify.
  6. Update schedule-only field on coaching template

    • Trigger: PATCH /:id { daysOfWeek:['monday'] } on a coaching template.
    • Expected: 400 “daysOfWeek only editable on schedule templates”.
  7. Conflict apply: skip

    • Trigger: Mia already has an assignment on 2026-06-01 slot 0. Apply with conflictMode=‘skip’.
    • Expected: that one cell counts toward skipped; the rest of the cells insert normally.
  8. Conflict apply: abort

    • Trigger: Same setup, conflictMode=‘abort’.
    • Expected: 409, NO new rows inserted (transaction rolled back).
  9. Conflict apply: overwrite

    • Trigger: Same setup, conflictMode=‘overwrite’.
    • Expected: Existing conflicting row soft-deleted, new row inserted, overwritten count is 1. Results referencing the deleted assignment still resolve via FK.
  10. Feed apply with non-feed programId

    • Trigger: mode:'feed', programId:<scheduleProgramId>.
    • Expected: 400 “Target program is not in feed mode”.
  11. Soft-delete a template

    • Trigger: DELETE /:id.
    • Expected: 204 / 200; list no longer returns it; existing workout_assignments already applied from it are untouched.

Permissions

  1. Member calls any program-templates endpoint

    • Expected: 403 from requireCoach.
  2. Cross-org access

    • Trigger: Casey (org A) calls GET /organizations/<orgB>/program-templates.
    • Expected: 403/404.

i18n

  1. Template builder in Hebrew (RTL)

    • Trigger: switch to he.
    • Expected: all builder labels translated; cell menus, apply dialog, preview text, conflictMode chooser. RTL layout.
  2. Template builder in Russian

    • Trigger: switch to ru.
    • Expected: full translation.

What “broken” looks like

  • An apply partially writes (e.g. some class_sessions but no daily_programming) — transaction wrap is missing.
  • Schedule template apply uses UTC instead of org timezone (sessions land at the wrong wall-clock time).
  • Conflict abort rolls back partially (some users’ rows survived).
  • upsertCells does not delete existing cells before re-inserting (orphan rows accumulate).
  • from-history on schedule source returns daysOfWeek translated with the wrong weekday index (e.g. Sunday vs Monday off-by-one).
  • A non-coach manages to apply a template (role gate bypassed).
  • applyToFeed writes feed posts for kind='rest' cells (rest should always skip).
  • Library deleted workout still referenced by a template cell allows apply to fail with an FK error instead of the cleaner “workout not found” 400.