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 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 — schedule template

  1. Create schedule template

    • Trigger: POST /program-templates { name:'Mon/Wed/Fri 7am', deliveryMode:'schedule', durationWeeks:4, slots:[{ classTypeId, occurrences:[{day:'monday',time:'07:00'},{day:'wednesday',time:'07:00'},{day:'friday',time:'07:00'}] }] }.
    • 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 occurrences), all with status='draft', startsAt computed in org timezone → UTC. No daily_programming rows — apply is pure recurrence.
  4. Mode-mismatch apply

    • Trigger: POST /:id/apply on a schedule template with { mode:'coaching', ... }.
    • 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 one slot whose occurrences[] are the unique (weekday, time) pairs 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 { slots:[...] } on a coaching template.
    • Expected: 400 “slots 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.

21a. Schedule re-apply is idempotent - Trigger: Apply a schedule template, then apply it again with the same startDate (no conflictMode). - Expected: Second run reports created: 0, skipped: N; the calendar still holds exactly one session per (class type, date, time). Covered by schedule-apply-conflicts.int.spec.ts.

21b. Schedule conflict: partial overlap - Trigger: A 60-minute WOD already sits at 07:30; apply a template placing WOD at 07:00 on that date. - Expected: Counted as a conflict (intervals intersect), not created. A WOD at 08:00 would NOT conflict.

21c. Schedule conflict: different class type at the same time - Trigger: Yoga exists Monday 07:00; apply a template placing WOD Monday 07:00. - Expected: No conflict — both sessions coexist.

21d. Schedule conflict: cancelled / soft-deleted sessions are free - Trigger: The only session at that slot is status='cancelled' (or deleted_at set); apply over it. - Expected: No conflict, session created.

21e. Schedule overwrite protects booked sessions - Trigger: Two clashing sessions, one with a confirmed booking; apply with conflictMode='overwrite'. - Expected: The unbooked one is soft-deleted and replaced (overwritten: 1, with an audit_logs row class_session.delete carrying reason: 'program_template_apply_overwrite'); the booked one survives untouched and is counted in blocked. A cancelled booking does not protect a session.

21f. Schedule conflicts are reported at template coordinates - Trigger: Apply a 52-week template whose Monday 07:00 clashes every week; read the conflicts[] payload. - Expected: ONE group { classTypeName, day:'monday', time:'07:00', count: 52, dates: [...first 10] }, not 52 rows. The preview returns the same payload plus plannedCount / sessionCount / blockedCount, and writes nothing.

  1. 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 overwrite apply partially writes (a session soft-deleted but its replacement missing) — 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 derives occurrences with the wrong weekday index (e.g. Sunday vs Monday off-by-one).
  • A non-coach manages to apply a template (role gate bypassed).
  • 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.