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

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.

Override flow

  1. Override a canonical row

    • Trigger: Casey PUT /exercises/<canonicalId>/override with { overrides: { name: 'סקוואט אחורי', cues: ['חזה למעלה'], videoUrl: 'https://...' } }.
    • Expected: merged effective row returns name='סקוואט אחורי', customizedFields=['cues','name','videoUrl'], isCustomizedByOrg=true. Override row exists in exercise_org_overrides.
  2. Update override (additive)

    • Trigger: another PUT with { overrides: { primaryMuscles: ['quadriceps','glutes'] } }.
    • Expected: merged result preserves name from step 9 and adds primaryMuscles. The override JSONB is merged, not replaced.
  3. Override with unknown keys

    • Trigger: PUT with { overrides: { foo:'bar', name:'X' } }.
    • Expected: only name retained; foo stripped. customizedFields does not contain foo.
  4. Override on an org-custom row

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

    • Trigger: 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 override is unchanged; org B’s view shows org B’s override. Two separate exercise_org_overrides rows.

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.