Courses — QA Plan
Smoke
| # | Scenario | Expected |
|---|---|---|
| S1 | Trainer creates a course | POST /organizations/:orgId/courses { name, durationDays }. programs row + course_configs (status='draft') created. |
| S2 | Trainer sets day 0 with two workouts | POST /:id/days { dayOffset: 0, workoutIds: [a, b] }. Two course_workouts rows inserted (sort_order 0, 1). |
| S3 | Trainer sets day 1 as rest | POST /:id/days { dayOffset: 1, isRest: true }. One row with is_rest=true, workout_id=null, sort_order=0. |
| S4 | Set price | PATCH /:id/price { priceInCents: 19900 }. Upserts a plans row (type='course', program_id=:id, currency from org). |
| S5 | Publish with no curriculum | 400 Add at least one workout to the course before publishing. |
| S6 | Publish with curriculum | status → published, publishedAt stamped. |
| S7 | Lite-tier owner publishes | 403 Feature "courses" requires a higher platform tier. |
| S8 | Archive a published course | status → archived. Storefront returns 404. Existing buyers retain access. |
| S9 | Public storefront for draft | 404. |
| S10 | Public storefront with preview_type='first_week' | Returns days 0–6 workouts as preview.workouts. |
Checkout
| # | Scenario | Expected |
|---|---|---|
| C1 | Buyer signed-in (category A), paid course | POST /public/courses/:id/checkout. Entitlement created pending; paymentPageUrl returned. Webhook flips to active. |
| C2 | Buyer signed-in (category B), paid course | Lead created with source='course_purchase'. Entitlement pending with membership_id=null. |
| C3 | Buyer signed-in, free course | Entitlement directly active. paymentPageUrl=null. |
| C4 | Buyer not signed-in, existing email | Web app signIn.create → email_code → setActive → auto-fires checkout. |
| C5 | Buyer not signed-in, new email | Web app signUp.create → email_code → setActive → auto-fires checkout. |
| C6 | Buyer already owns course | Service throws COURSE_ALREADY_OWNED → controller 409. Web app redirects to library. |
| C7 | Buyer abandons paid checkout, returns | Entitlement still pending. New checkout call reactivates same row (ensureEntitlementForCheckout). |
| C8 | Payment fails | Webhook payment.failed → entitlement revoked, accessRevokedAt stamped. |
| C9 | Buyer of revoked entitlement re-purchases | ensureEntitlementForCheckout reactivates row to pending; on success flips to active with accessRevokedAt=null. |
| C10 | Cross-origin successUrl injection attempt | Web app’s sameOriginOrFallback rejects the URL and uses the default (buy/courses/[id]/page.tsx:89). |
Cross-org abuse (critical)
| # | Scenario | Expected |
|---|---|---|
| X1 | Webhook to org A’s URL carries metadata.courseEntitlementId from org B | Handler joins entitlement → programs, sees mismatch → logs Cross-org webhook abuse blocked, captures Sentry, no DB mutation. |
| X2 | Same on payment failure path | Same guard at webhook-processing.service.ts:362. |
| X3 | Buyer probes GET /me/courses/:id/workouts/:workoutId with a workout id from a different course in the same org | requireCourseWorkout checks (programId, workoutId) → 404. |
Player
| # | Scenario | Expected |
|---|---|---|
| P1 | List my courses | GET /me/courses returns all active entitlements across orgs. |
| P2 | Pending entitlement | GET /me/courses/:id → 403 Payment is still being processed. |
| P3 | Revoked entitlement | 403 Course access has been revoked. |
| P4 | Start a course | POST /me/courses/:id/start — stamps startDate. Defaults to today. |
| P5 | Mark workout complete | POST …/complete — completion row inserted. Idempotent: second call no-ops. |
| P6 | Mark all curriculum workouts complete | entitlement.completedAt is set automatically (refreshEntitlementCompletedAt). |
| P7 | Unmark one workout | Completion deleted; completedAt cleared. |
| P8 | Restart | startDate=today, restartCount++, completedAt=null, all completions deleted. |
| P9 | Read workout | GET /me/courses/:id/workouts/:workoutId — returns sections + movements + exercises. 404 if workout isn’t in curriculum. |
| P10 | Read workout of unowned course | 403 You do not own this course. |
Trainer reporting
| # | Scenario | Expected |
|---|---|---|
| T1 | List enrollments | GET /:id/enrollments returns only status='active' buyers (pending and revoked hidden). |
| T2 | Per-buyer fields | currentDayOffset, completedDays, completedWorkouts, lastActivityAt, sourceLeadId. |
| T3 | Day completion stats | loadDayCompletionStats returns { dayOffset, completedCount, totalStartedBuyers } per day with at least one non-rest workout. |
| T4 | Coach lists courses | 200 — staff can read. |
| T5 | Coach creates a course | 200 (coach is a staff role). |
| T6 | Member creates a course | 403 Only staff can create courses. |
Permissions
| # | Scenario | Expected |
|---|---|---|
| Pe1 | Anonymous hits /public/courses/:id/checkout | 401 (controller checks Clerk session). |
| Pe2 | Member of org A buys course from org B (category B) | Allowed; lead created in org B. |
| Pe3 | Lite owner reads list of own courses | 200 — GET /organizations/:orgId/courses is not feature-gated (only mutating routes are). |
| Pe4 | Lite owner creates a course | 403 (@RequiresFeature('courses') is Pro+). |
Refunds / chargebacks
| # | Scenario | Expected (today) | Desired (gap) |
|---|---|---|---|
| R1 | Owner refunds a course charge via POST /payments/:txnId/refund | Manual refund task opens. Original txn → refund_pending. Entitlement unchanged — buyer still has access. | Auto-revoke entitlement on refund success (FIT-tbd). |
| R2 | Owner closes the refund task | Txn → refunded. Entitlement still active. | Cascade revoke. |
| R3 | Chargeback flows in via refund.completed webhook | Txn → refunded. Same gap. | Same. |
Platform fee
| # | Scenario | Expected (today) |
|---|---|---|
| PF1 | Inspect a course purchase txn | payment_transactions row has the full price; no platform_fee_in_cents column exists. 0% cut — FIT-147/148. |
| PF2 | Money flow | Payment 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_entitlementsafter 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.