Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesExercisesExercises — QA plan

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)

  1. 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. total reflects the full filter set.
  2. Filter by category

    • Trigger: ?category=cardio.
    • Expected: only cardio rows.
  3. Filter by equipment overlap

    • Trigger: ?equipment=barbell&equipment=kettlebell.
    • Expected: rows whose equipment[] overlaps either.
  4. Filter by movementPattern

    • Trigger: ?movementPattern=squat.
    • Expected: rows with movement_pattern='squat'.
  5. Filter by difficulty

    • Trigger: ?difficulty=3.
    • Expected: rows with difficulty=3.
  6. Free-text search

    • Trigger: ?q=thrust.
    • Expected: rows matching name ILIKE OR alias EXISTS-unnest match.
  7. Source filter custom

    • Trigger: ?source=custom.
    • Expected: only org-A’s customs.
  8. Source filter customized

    • Trigger: ?source=customized.
    • Expected: only canonical rows that org A has overridden; total recomputed from override count.
  9. 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; total is the sum of both counts.

Canonical customization and legacy overrides

  1. Override a canonical row

    • Trigger: Casey PUT /exercises/<canonicalId>/override with { overrides: { name: 'סקוואט אחורי', cues: ['חזה למעלה'], videoUrl: 'https://...' } }.
    • Expected: a private exercise row returns name='סקוואט אחורי', organizationId=<orgA>, and forkedFromId=<canonicalId>. The canonical row stays unchanged.
  2. Update override (additive)

    • Trigger: another PUT with { overrides: { primaryMuscles: ['quadriceps','glutes'] } }.
    • Expected: merged result preserves name from step 9 and adds primaryMuscles. The same private fork is updated; omitted fields are preserved.
  3. Override with unknown keys

    • Trigger: PUT with { overrides: { foo:'bar', name:'X' } }.
    • Expected: only name retained; foo stripped from the private copy.
  4. Override on an org-custom row

    • Trigger: PUT /exercises/<orgCustomId>/override.
    • Expected: 400 “Overrides can only target canonical exercises”.
  5. Reset override

    • Trigger: seed a pre-existing legacy override, then DELETE /exercises/<canonicalId>/override.
    • Expected: 200 with isCustomizedByOrg=false, customizedFields=[]. Override row gone from exercise_org_overrides.
  6. Reset override on a clean canonical row (no existing override)

    • Trigger: DELETE again.
    • Expected: 200, idempotent (no row to delete, no error).
  7. 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

  1. Create org-custom

    • Trigger: POST /exercises { name:'Sled Push', equipment:'sled,rope', category:'cardio' }.
    • Expected: row created with equipment=['sled','rope'] (splitToArray on comma).
  2. Update org-custom

    • Trigger: PATCH /exercises/:id { name:'Heavy Sled Push' }.
    • Expected: updated.
  3. Soft-delete org-custom

    • Trigger: DELETE /exercises/:id.
    • Expected: 200; row has deleted_at set; library list no longer shows it.
  4. Upsert by name (idempotent)

    • Trigger: POST /exercises/upsert { name:'Heavy Sled Push' }.
    • Expected: returns existing row, no duplicate. Case-insensitive.
  5. Upsert when name is new

    • Trigger: POST /exercises/upsert { name:'Yoke Carry' }.
    • Expected: creates new row.
  1. Lexical search

    • Trigger: GET /exercises/search?q=back squat&mode=lexical&orgId=<orgA>.
    • Expected: top result is Back Squat (canonical or org override).
  2. 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.
  3. Hybrid search

    • Trigger: ?mode=hybrid&q=hinge.
    • Expected: Deadlift / RDL variants returned; RRF combines lexical + semantic. No duplicates.
  4. Search without orgId

    • Trigger: ?q=fran&mode=hybrid.
    • Expected: canonical-only results; no org rows.
  5. Search with a non-member orgId

    • Trigger: Casey calls ?orgId=<orgB>.
    • Expected: silently falls back to canonical-only (no error).
  6. 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).
  7. Empty q for 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.

Workout-builder integration

  1. Pick canonical exercise in builder

    • Trigger: Add a movement referencing a canonical id to a new workout.
    • Expected: 200 from POST /workouts.
  2. 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

  1. Enqueue seed job

    • Trigger: Pat POST /admin/exercises/canonical/seed with R2 key DTO.
    • Expected: { jobId, queue:'canonical-seed' }. Job appears in GET /admin/.../jobs.
  2. Inspect job

    • Trigger: GET /admin/exercises/canonical/jobs/canonical-seed/<id>?logsTail=50.
    • Expected: state + progress + last 50 log lines.
  3. Non-admin attempts admin endpoint

    • Trigger: Casey calls POST /admin/exercises/canonical/seed.
    • Expected: 403 from @PlatformAdmin() guard.

Permissions

  1. Member tries to PATCH canonical via override

    • Trigger: Mia PUT /exercises/:id/override.
    • Expected: 403 “Insufficient permissions”.
  2. Member browses library

    • Trigger: Mia GET /exercises/library.
    • Expected: 200, can browse (read access only).

i18n

  1. 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.
  2. 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 or IMMUTABLE function wrapper broke).
  • Search latency >300ms — HNSW index missing or vector embed not cached.
  • Equipment / aliases CSV parsing introduces empty strings on edge inputs (splitToArray should filter).
  • Reset override of a non-existent override raises an error instead of being idempotent.