Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesCoursesCourses — QA Plan

Courses — QA Plan

Smoke

#ScenarioExpected
S1Trainer creates a coursePOST /organizations/:orgId/courses { name, durationDays }. programs row + course_configs (status='draft') created.
S2Trainer sets day 0 with two workoutsPOST /:id/days { dayOffset: 0, workoutIds: [a, b] }. Two course_workouts rows inserted (sort_order 0, 1).
S3Trainer sets day 1 as restPOST /:id/days { dayOffset: 1, isRest: true }. One row with is_rest=true, workout_id=null, sort_order=0.
S4Set pricePATCH /:id/price { priceInCents: 19900 }. Upserts a plans row (type='course', program_id=:id, currency from org).
S5Publish with no curriculum400 Add at least one workout to the course before publishing.
S6Publish with curriculumstatus → published, publishedAt stamped.
S7Lite-tier owner publishes403 Feature "courses" requires a higher platform tier.
S8Archive a published coursestatus → archived. Storefront returns 404. Existing buyers retain access.
S9Public storefront for draft404.
S10Public storefront with preview_type='first_week'Returns days 0–6 workouts as preview.workouts.

Checkout

#ScenarioExpected
C1Buyer signed-in (category A), paid coursePOST /public/courses/:id/checkout. Entitlement created pending; paymentPageUrl returned. Webhook flips to active.
C2Buyer signed-in (category B), paid courseLead created with source='course_purchase'. Entitlement pending with membership_id=null.
C3Buyer signed-in, free courseEntitlement directly active. paymentPageUrl=null.
C4Buyer not signed-in, existing emailWeb app signIn.create → email_code → setActive → auto-fires checkout.
C5Buyer not signed-in, new emailWeb app signUp.create → email_code → setActive → auto-fires checkout.
C6Buyer already owns courseService throws COURSE_ALREADY_OWNED → controller 409. Web app redirects to library.
C7Buyer abandons paid checkout, returnsEntitlement still pending. New checkout call reactivates same row (ensureEntitlementForCheckout).
C8Payment failsWebhook payment.failed → entitlement revoked, accessRevokedAt stamped.
C9Buyer of revoked entitlement re-purchasesensureEntitlementForCheckout reactivates row to pending; on success flips to active with accessRevokedAt=null.
C10Cross-origin successUrl injection attemptWeb app’s sameOriginOrFallback rejects the URL and uses the default (buy/courses/[id]/page.tsx:89).

Cross-org abuse (critical)

#ScenarioExpected
X1Webhook to org A’s URL carries metadata.courseEntitlementId from org BHandler joins entitlement → programs, sees mismatch → logs Cross-org webhook abuse blocked, captures Sentry, no DB mutation.
X2Same on payment failure pathSame guard at webhook-processing.service.ts:362.
X3Buyer probes GET /me/courses/:id/workouts/:workoutId with a workout id from a different course in the same orgrequireCourseWorkout checks (programId, workoutId) → 404.

Player

#ScenarioExpected
P1List my coursesGET /me/courses returns all active entitlements across orgs.
P2Pending entitlementGET /me/courses/:id → 403 Payment is still being processed.
P3Revoked entitlement403 Course access has been revoked.
P4Start a coursePOST /me/courses/:id/start — stamps startDate. Defaults to today.
P5Mark workout completePOST …/complete — completion row inserted. Idempotent: second call no-ops.
P6Mark all curriculum workouts completeentitlement.completedAt is set automatically (refreshEntitlementCompletedAt).
P7Unmark one workoutCompletion deleted; completedAt cleared.
P8RestartstartDate=today, restartCount++, completedAt=null, all completions deleted.
P9Read workoutGET /me/courses/:id/workouts/:workoutId — returns sections + movements + exercises. 404 if workout isn’t in curriculum.
P10Read workout of unowned course403 You do not own this course.

Trainer reporting

#ScenarioExpected
T1List enrollmentsGET /:id/enrollments returns only status='active' buyers (pending and revoked hidden).
T2Per-buyer fieldscurrentDayOffset, completedDays, completedWorkouts, lastActivityAt, sourceLeadId.
T3Day completion statsloadDayCompletionStats returns { dayOffset, completedCount, totalStartedBuyers } per day with at least one non-rest workout.
T4Coach lists courses200 — staff can read.
T5Coach creates a course200 (coach is a staff role).
T6Member creates a course403 Only staff can create courses.

Permissions

#ScenarioExpected
Pe1Anonymous hits /public/courses/:id/checkout401 (controller checks Clerk session).
Pe2Member of org A buys course from org B (category B)Allowed; lead created in org B.
Pe3Lite owner reads list of own courses200 — GET /organizations/:orgId/courses is not feature-gated (only mutating routes are).
Pe4Lite owner creates a course403 (@RequiresFeature('courses') is Pro+).

Refunds / chargebacks

#ScenarioExpected (today)Desired (gap)
R1Owner refunds a course charge via POST /payments/:txnId/refundManual refund task opens. Original txn → refund_pending. Entitlement unchanged — buyer still has access.Auto-revoke entitlement on refund success (FIT-tbd).
R2Owner closes the refund taskTxn → refunded. Entitlement still active.Cascade revoke.
R3Chargeback flows in via refund.completed webhookTxn → refunded. Same gap.Same.

Platform fee

#ScenarioExpected (today)
PF1Inspect a course purchase txnpayment_transactions row has the full price; no platform_fee_in_cents column exists. 0% cut — FIT-147/148.
PF2Money flowPayment lands in the gym’s own provider account. FitKit never touches it.

E2E

  • apps/web/e2e/specs/buy-course.spec.ts — full storefront → email-code → Cardcom test card → library → start course flow.
  • apps/web/e2e/specs/course-player.spec.ts — mark/unmark/restart inside the player.
  • apps/web/e2e/specs/courses-admin.spec.ts — trainer create/curriculum/publish flow.

Gaps requiring manual QA

  • Refund does not revoke entitlement automatically — verify by inspecting course_entitlements after each refund.
  • Cross-org webhook abuse blocks are best verified via Sentry alerts; there’s no admin UI.
  • Pending entitlement cleanup: stale pendings accumulate forever today. Watch for buyer reports of “can’t repurchase” after a long-abandoned attempt.
  • Receipt email is not sent to category-B buyers (no membership_id); confirm with the buyer manually if it matters.