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
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:
Override flow
-
Override a canonical row
- Trigger: Casey
PUT /exercises/<canonicalId>/overridewith{ overrides: { name: 'סקוואט אחורי', cues: ['חזה למעלה'], videoUrl: 'https://...' } }. - Expected: merged effective row returns
name='סקוואט אחורי',customizedFields=['cues','name','videoUrl'],isCustomizedByOrg=true. Override row exists inexercise_org_overrides.
- Trigger: Casey
-
Update override (additive)
- Trigger: another
PUTwith{ overrides: { primaryMuscles: ['quadriceps','glutes'] } }. - Expected: merged result preserves
namefrom step 9 and addsprimaryMuscles. The override JSONB is merged, not replaced.
- Trigger: another
-
Override with unknown keys
- Trigger:
PUTwith{ overrides: { foo:'bar', name:'X' } }. - Expected: only
nameretained;foostripped.customizedFieldsdoes not containfoo.
- Trigger:
-
Override on an org-custom row
- Trigger:
PUT /exercises/<orgCustomId>/override. - Expected: 400 “Overrides can only target canonical exercises”.
- Trigger:
-
Reset override
- Trigger:
DELETE /exercises/<canonicalId>/override. - Expected: 200 with
isCustomizedByOrg=false,customizedFields=[]. Override row gone fromexercise_org_overrides.
- Trigger:
-
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 override is unchanged; org B’s view shows org B’s override. Two separate
exercise_org_overridesrows.
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.