Exercises — QA plan
Pre-requisites
- Local stack with the canonical library seeded (≥ 100 movements). Run the canonical seed job if missing.
- Two orgs (A and B) for cross-org isolation tests.
- Personas: Coach Casey (org A), Owner Olga (org A), Member Mia (org A), Coach Bob (org B), Platform Admin Pat.
- Voyage/Anthropic credentials configured for semantic-search and enrichment tests; otherwise expect graceful fallback.
Test scenarios
Organization forks
In org A, customize a canonical movement, then edit the returned copy again. Both edits must use the same private ID. The canonical row and org B’s view must stay unchanged; org B must be unable to edit or resolve org A’s copy. Selection search should prefer org A’s fork, while the library can show both rows.
Include existing drafts, unpublished scheduled releases, published workouts, and logged results. Drafts should use the fork; published and historical references should retain the canonical ID. Repeat with one source shared by draft and published content. Verify source text and score definitions survive any draft copy.
Repeat with PostHog unavailable: ownership and fork behavior must remain enforced. Existing legacy overrides remain readable and can be reset; new customizations create private forks.
Library browse (coach)
-
List merged library
- Trigger: Casey hits
GET /organizations/<orgA>/exercises/library?page=1&pageSize=50. - Expected: 50 rows returned, mix of canonical (
isOrgCustom=false,isCustomizedByOrg=false) and any org-customs.totalreflects the full filter set.
- Trigger: Casey hits
-
Filter by category
- Trigger:
?category=cardio. - Expected: only
cardiorows.
- Trigger:
-
Filter by equipment overlap
- Trigger:
?equipment=barbell&equipment=kettlebell. - Expected: rows whose
equipment[]overlaps either.
- Trigger:
-
Filter by movementPattern
- Trigger:
?movementPattern=squat. - Expected: rows with
movement_pattern='squat'.
- Trigger:
-
Filter by difficulty
- Trigger:
?difficulty=3. - Expected: rows with
difficulty=3.
- Trigger:
-
Free-text search
- Trigger:
?q=thrust. - Expected: rows matching name ILIKE OR alias EXISTS-unnest match.
- Trigger:
-
Source filter
custom- Trigger:
?source=custom. - Expected: only org-A’s customs.
- Trigger:
-
Source filter
customized- Trigger:
?source=customized. - Expected: only canonical rows that org A has overridden;
totalrecomputed from override count.
- Trigger:
-
Source filter
mine(the movements list’s “My gym’s” chip)- Trigger:
?source=mine. - Expected: org A’s customs plus canonical rows org A has overridden, deduped;
totalis the sum of both counts.
- Trigger:
Canonical customization and legacy overrides
-
Override a canonical row
- Trigger: Casey
PUT /exercises/<canonicalId>/overridewith{ overrides: { name: 'סקוואט אחורי', cues: ['חזה למעלה'], videoUrl: 'https://...' } }. - Expected: a private exercise row returns
name='סקוואט אחורי',organizationId=<orgA>, andforkedFromId=<canonicalId>. The canonical row stays unchanged.
- Trigger: Casey
-
Update override (additive)
- Trigger: another
PUTwith{ overrides: { primaryMuscles: ['quadriceps','glutes'] } }. - Expected: merged result preserves
namefrom step 9 and addsprimaryMuscles. The same private fork is updated; omitted fields are preserved.
- Trigger: another
-
Override with unknown keys
- Trigger:
PUTwith{ overrides: { foo:'bar', name:'X' } }. - Expected: only
nameretained;foostripped from the private copy.
- Trigger:
-
Override on an org-custom row
- Trigger:
PUT /exercises/<orgCustomId>/override. - Expected: 400 “Overrides can only target canonical exercises”.
- Trigger:
-
Reset override
- Trigger: seed a pre-existing legacy override, then
DELETE /exercises/<canonicalId>/override. - Expected: 200 with
isCustomizedByOrg=false,customizedFields=[]. Override row gone fromexercise_org_overrides.
- Trigger: seed a pre-existing legacy override, then
-
Reset override on a clean canonical row (no existing override)
- Trigger:
DELETEagain. - Expected: 200, idempotent (no row to delete, no error).
- Trigger:
-
Cross-org override isolation
- Trigger: Coach Bob (org B) overrides the same canonical. Casey re-reads org A’s library detail.
- Expected: org A’s copy is unchanged; org B sees its own separate private copy. Both reference the canonical source through
forkedFromId.
Org-custom CRUD
-
Create org-custom
- Trigger:
POST /exercises{ name:'Sled Push', equipment:'sled,rope', category:'cardio' }. - Expected: row created with
equipment=['sled','rope'](splitToArray on comma).
- Trigger:
-
Update org-custom
- Trigger:
PATCH /exercises/:id{ name:'Heavy Sled Push' }. - Expected: updated.
- Trigger:
-
Soft-delete org-custom
- Trigger:
DELETE /exercises/:id. - Expected: 200; row has
deleted_atset; library list no longer shows it.
- Trigger:
-
Upsert by name (idempotent)
- Trigger:
POST /exercises/upsert{ name:'Heavy Sled Push' }. - Expected: returns existing row, no duplicate. Case-insensitive.
- Trigger:
-
Upsert when name is new
- Trigger:
POST /exercises/upsert{ name:'Yoke Carry' }. - Expected: creates new row.
- Trigger:
Hybrid search
-
Lexical search
- Trigger:
GET /exercises/search?q=back squat&mode=lexical&orgId=<orgA>. - Expected: top result is Back Squat (canonical or org override).
- Trigger:
-
Semantic search
- Trigger:
GET /exercises/search?q=knee dominant squat with bar&mode=semantic&orgId=<orgA>. - Expected: Back Squat / Front Squat top results despite no exact term match.
- Trigger:
-
Hybrid search
- Trigger:
?mode=hybrid&q=hinge. - Expected: Deadlift / RDL variants returned; RRF combines lexical + semantic. No duplicates.
- Trigger:
-
Search without orgId
- Trigger:
?q=fran&mode=hybrid. - Expected: canonical-only results; no org rows.
- Trigger:
-
Search with a non-member orgId
- Trigger: Casey calls
?orgId=<orgB>. - Expected: silently falls back to canonical-only (no error).
- Trigger: Casey calls
-
Search where org override changes the name
- Trigger: Org A has overridden Back Squat’s name to Hebrew; search with
q=סקוואט&orgId=<orgA>. - Expected: returns the overridden row; dedup keeps a single result (not two — both org and canonical).
- Trigger: Org A has overridden Back Squat’s name to Hebrew; search with
-
Empty
qfor lexical mode- Trigger:
?mode=lexical&q=. - Expected: TODO: verify — current code skips lexical when
q.length === 0; the request may return no results or include only filter-only matches.
- Trigger:
Workout-builder integration
-
Pick canonical exercise in builder
- Trigger: Add a movement referencing a canonical id to a new workout.
- Expected: 200 from
POST /workouts.
-
Pick cross-org exercise in builder
- Trigger: API call with org B’s custom exercise id while creating an org A workout.
- Expected: 400 “One or more exercises not found in this organization or the canonical library”.
Platform-admin
-
Enqueue seed job
- Trigger: Pat
POST /admin/exercises/canonical/seedwith R2 key DTO. - Expected:
{ jobId, queue:'canonical-seed' }. Job appears inGET /admin/.../jobs.
- Trigger: Pat
-
Inspect job
- Trigger:
GET /admin/exercises/canonical/jobs/canonical-seed/<id>?logsTail=50. - Expected: state + progress + last 50 log lines.
- Trigger:
-
Non-admin attempts admin endpoint
- Trigger: Casey calls
POST /admin/exercises/canonical/seed. - Expected: 403 from
@PlatformAdmin()guard.
- Trigger: Casey calls
Permissions
-
Member tries to PATCH canonical via override
- Trigger: Mia
PUT /exercises/:id/override. - Expected: 403 “Insufficient permissions”.
- Trigger: Mia
-
Member browses library
- Trigger: Mia
GET /exercises/library. - Expected: 200, can browse (read access only).
- Trigger: Mia
i18n
-
Aliases include Hebrew + Russian
- Trigger: Inspect a canonical row that’s been enriched.
- Expected:
aliases[]contains Hebrew and Russian variants. Search by Hebrew name returns the canonical row.
-
Override with Hebrew name renders RTL
- Trigger: After step 9, view the workout that uses the overridden exercise in the Hebrew locale.
- Expected: Hebrew name displayed with RTL alignment.
What “broken” looks like
- Override merge replaces other fields (every PUT wipes the previous override JSON).
- Search returns both canonical Back Squat AND its org override as two separate results.
- A coach manages to override an org-custom row (should be 400).
- Org A’s override leaks into Org B’s library view.
- Soft-deleted exercises appear in
GET /exercises/library. - A canonical row’s name change does NOT update
search_tsv(the generated column orIMMUTABLEfunction wrapper broke). - Search latency >300ms — HNSW index missing or vector embed not cached.
- Equipment / aliases CSV parsing introduces empty strings on edge inputs (
splitToArrayshould filter). - Reset override of a non-existent override raises an error instead of being idempotent.