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 (single class type)

    • 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, read in the org’s timezone (not UTC).

11a. Save this week as a template (whole program) - Trigger: From the schedule screen’s setup menu, “Save this week as a template” on a program with 2+ active class types that both ran sessions this week. POST /from-history { source:'schedule', programId:<programId>, startDate, endDate, name }. - Expected: One template with one program_template_slots row per class type that had a session, ordered by class type name; a class type with no sessions this week gets no slot (not an error). Dialog shows the program’s active class types as chips, no picker.

11b. Save this week as a template — non-UTC org timezone - Pre-requisite: org timezone = 'Asia/Jerusalem'. - Trigger: A class runs Monday at 19:00 local (16:00 UTC in IDT). Save this week as a template for that program. - Expected: The saved slot’s occurrence is { day:'monday', time:'19:00' } — NOT '16:00'. Re-applying the template from startDate lands the session back at 19:00 local, matching the source week’s wall-clock time.

  1. From-history with empty range
    • Trigger: Range with no data.
    • Expected: 400 “No data in the selected range” (coaching) / “Cannot derive recurrence — no class sessions in range…” (schedule, when no class type in scope has a session).

12a. From-history schedule source with neither/both programId and sourceId - Trigger: POST /from-history { source:'schedule', startDate, endDate, name } with neither id, then with both. - Expected: 400 in both cases (“Provide either…” / “Provide only one of…”).

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), or reads UTC weekday/time instead of the org timezone.
  • 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.