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.
- 5+ library workouts (
- Personas: Coach Casey, Owner Olga, Member Mia (any one of the 3 athletes works for verification).
- Org
timezoneset to a non-UTC value (e.g.Asia/Jerusalem) to validate timezone math.
Test scenarios
Golden path — coaching template
-
Create header
- Trigger:
POST /program-templateswith{ name:'8wk Strength', deliveryMode:'coaching', durationWeeks:8 }. - Expected: 201 with empty cells array.
- Trigger:
-
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/workoutssucceeds; DB has 4program_template_workoutsrows. CHECKs accept each cell.
-
Preview against cohort
- Trigger:
POST /:id/previewwith{ mode:'coaching', startDate:'2026-06-01', userIds:[<mia>,<niv>,<lior>], conflictMode:'skip' }. - Expected: preview returns expected
createdcount (cells × athletes),skippedcount for existing conflicts (should be 0 on a clean calendar), no DB writes.
- Trigger:
-
Apply
- Trigger:
POST /:id/applywith the same body. - Expected:
createdcount matches preview. Newworkout_assignmentsrows in DB: kind=workout with both pointers set; kind=rest with both pointers null; kind=note withnotetext set. All in pointer state (snapshot_workout_id = workout_idfor workout kind).
- Trigger:
-
Verify member view
- Trigger: Mia visits whiteboard on 2026-06-01.
- Expected: assigned Back Squat 5x5 visible, but only if
published=true— note thatapplyToCoachingwritespublished=false, so confirm publishing flow.
Golden path — schedule template
-
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.
- Trigger:
-
Add workout to Wednesday week 1
- Trigger: Cell at week=1, dayOffset=3 (Wed) with workout.
- Expected: 1 cell row.
-
Apply
- Trigger:
POST /:id/apply{ mode:'schedule', startDate:'2026-06-01' }(Monday). - Expected: 12
class_sessions(4 wks × 3 occurrences), all withstatus='draft',startsAtcomputed in org timezone → UTC. Nodaily_programmingrows — apply is pure recurrence.
- Trigger:
-
Mode-mismatch apply
- Trigger:
POST /:id/applyon a schedule template with{ mode:'coaching', ... }. - Expected: 400 from
assertModeMatches.
- Trigger:
Save-as-template (from-history)
-
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.
- Trigger:
-
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.
- Trigger:
-
From-history with empty range
- Trigger: Range with no data.
- Expected: 400 “No data in the selected range”.
Edge cases
-
Cell weekNumber > durationWeeks
- Trigger: durationWeeks=4, cell with weekNumber=5.
- Expected: 400.
-
Cell with cross-org workout
- Trigger: workoutId from a different org.
- Expected: 400 from
assertLibraryWorkoutInOrg.
-
Cell with snapshot workout id
- Trigger: workoutId pointing at a
is_snapshot=truerow. - Expected: 400 (snapshot is excluded from “library” check).
- Trigger: workoutId pointing at a
-
Note cell missing text
- Trigger:
kind:'note', coachNote:null. - Expected: 400 “coachNote required when kind=‘note’”.
- Trigger:
-
Workout cell with stray coachNote
- Trigger:
kind:'workout', workoutId:X, coachNote:'foo'. - Expected: API accepts (only
workoutIdis required), but DB CHECKprogram_template_workouts_kind_payload_chkrejects (coachNote must be null). Verify which error surfaces and consider if API should validate first. TODO: verify.
- Trigger:
-
Update schedule-only field on coaching template
- Trigger:
PATCH /:id{ slots:[...] }on a coaching template. - Expected: 400 “slots only editable on schedule templates”.
- Trigger:
-
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.
-
Conflict apply: abort
- Trigger: Same setup, conflictMode=‘abort’.
- Expected: 409, NO new rows inserted (transaction rolled back).
-
Conflict apply: overwrite
- Trigger: Same setup, conflictMode=‘overwrite’.
- Expected: Existing conflicting row soft-deleted, new row inserted,
overwrittencount 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.
- Soft-delete a template
- Trigger:
DELETE /:id. - Expected: 204 / 200; list no longer returns it; existing
workout_assignmentsalready applied from it are untouched.
- Trigger:
Permissions
-
Member calls any program-templates endpoint
- Expected: 403 from
requireCoach.
- Expected: 403 from
-
Cross-org access
- Trigger: Casey (org A) calls
GET /organizations/<orgB>/program-templates. - Expected: 403/404.
- Trigger: Casey (org A) calls
i18n
-
Template builder in Hebrew (RTL)
- Trigger: switch to
he. - Expected: all builder labels translated; cell menus, apply dialog, preview text, conflictMode chooser. RTL layout.
- Trigger: switch to
-
Template builder in Russian
- Trigger: switch to
ru. - Expected: full translation.
- Trigger: switch to
What “broken” looks like
- An
overwriteapply 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
abortrolls back partially (some users’ rows survived). upsertCellsdoes not delete existing cells before re-inserting (orphan rows accumulate).from-historyon 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.