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.
- 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 — feed template
-
Create feed template
- Trigger:
POST /program-templates{ name:'WOD Feed', deliveryMode:'feed', durationWeeks:4 }. - Expected: 201.
- Trigger:
-
Fill 7-day cycle
- Trigger: Cells for every dayOffset 1..7 of week 1.
- Expected: 7 cells.
-
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 withpublishAt = local 06:00 Asia/Jerusalem → UTC.
- Trigger:
-
Verify timezone math
- Trigger: Inspect
workout_feed_posts.publish_atfor the first row. - Expected: For
Asia/Jerusalemsummer date, UTC =2026-06-01T03:00:00Z(DST applied viadate-fns-tz).
- Trigger: Inspect
Golden path — schedule template
-
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.
- 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 active days × 1 start time), all withstatus='draft',startsAtcomputed in org timezone → UTC. 1daily_programmingrow for the Wednesday workout.
- Trigger:
-
Mode-mismatch apply
- Trigger:
POST /:id/applyon a schedule template with{ mode:'feed', ... }. - 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
daysOfWeek+startTimesderived from the unique weekdays/times 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{ daysOfWeek:['monday'] }on a coaching template. - Expected: 400 “daysOfWeek 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.
-
Feed apply with non-feed programId
- Trigger:
mode:'feed', programId:<scheduleProgramId>. - Expected: 400 “Target program is not in feed mode”.
- Trigger:
-
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
applypartially writes (e.g. someclass_sessionsbut nodaily_programming) — 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 returnsdaysOfWeektranslated with the wrong weekday index (e.g. Sunday vs Monday off-by-one).- A non-coach manages to apply a template (role gate bypassed).
applyToFeedwrites feed posts forkind='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.