Scheduling & Bookings — QA Plan
Pre-requisites
- Test organization seeded with: 1 owner, 1 admin, 1 coach, 2 members; 1 program (
scheduledelivery mode); 2 class types; 1 location with lat/lng; 1 plan with 10 credits,maxBookingsPerDay=2,maxBookingsPerWeek=10,allowOverlappingBookings=false; member subscribed to that plan. - A second organization for cross-org isolation checks.
- The org’s
timezoneset to a non-UTC value (e.g.Asia/Jerusalem) so TZ bugs surface. cancellationWindowHours=2,allowLateCancellation=falsefor default policy tests.
Golden path scenarios
G1 — Owner creates and publishes a session
| Step | Action | Expected |
|---|---|---|
| 1 | Sign in as owner, navigate to /dashboard/schedule. | Calendar week loads. |
| 2 | Open “Create session”, pick class type, location, coach, start/end times, capacity=10, waitlist=5. | Form validates. |
| 3 | Submit with status=draft. | Session appears as draft (visible to staff, not members). |
| 4 | Click “Publish”. | Status flips to published; members can now see it on their schedule. |
G2 — Member books and is confirmed
| Step | Action | Expected |
|---|---|---|
| 1 | Sign in as member with active sub. Open /schedule. | Sees the published session with bookingEligibility.status='eligible', plan unblocked. |
| 2 | Tap “Book”. | Booking confirmed. Confirmation email arrives. Push notification arrives. |
| 3 | Check /bookings/my. | Booking listed with status confirmed. |
| 4 | Reload session. | myBookingStatus='confirmed'. Remaining credits decremented by 1. |
G3 — Member self-cancels with refund
| Step | Action | Expected |
|---|---|---|
| 1 | With G2 booking in place, well before cancellation deadline, tap “Cancel”. | Booking row → cancelled, cancelled_at set. Email sent with creditRefunded=true. Remaining credits +1. |
G4 — Waitlist auto-promotion
| Step | Action | Expected |
|---|---|---|
| 1 | Reduce a session’s capacity to 1 (or fully book it). Have member-A confirmed, member-B waitlisted (waitlist_position=1). | |
| 2 | Member-A cancels. | Member-B promoted: status flips to confirmed, waitlist_position=null. “You’re in” email sent. |
G5 — Staff manual check-in
| Step | Action | Expected |
|---|---|---|
| 1 | Coach opens session detail, sees roster with 5 confirmed members. | |
| 2 | Tap check-in on one member. | Booking status → attended; checkin_method='manual'. |
| 3 | Tap “Undo check-in”. | Status → confirmed; checked_in_at and checkin_method cleared. |
G6 — Member self-checkin via QR
| Step | Action | Expected |
|---|---|---|
| 1 | Staff generates display URL → QR code visible on a screen. | |
| 2 | Member scans within check-in window (default: TODO: verify window in isWithinCheckinWindow). | Booking → attended, checkin_method='qr'. |
| 3 | Scan again with the same QR. | selfCheckin finds no confirmed booking (already attended) → 400. |
G7 — Member self-checkin via GPS
| Step | Action | Expected |
|---|---|---|
| 1 | Location has lat/lng set. Member is physically within GPS_MAX_METERS. | |
| 2 | POST self-checkin with method=gps and current coords. | Booking → attended, checkin_method='gps'. |
| 3 | Repeat from > GPS_MAX_METERS away. | 400 with message GPS_TOO_FAR (exact string for client i18n). |
G8 — Owner bulk-publishes a week of drafts
| Step | Action | Expected |
|---|---|---|
| 1 | Owner has 14 draft sessions for next week (twice-daily). | |
| 2 | POST /sessions/bulk-preview with {programId, dateFrom, dateTo, status:'draft'}. | Returns {matched:14, withActiveBookings:0, samples:[10 rows]}. |
| 3 | POST /sessions/bulk-publish with the same filter. | Returns {batchId, publishedSessions:14}. All 14 flip to published. Audit log row exists. |
G9 — Owner bulk-deletes wrong-day sessions
| Step | Action | Expected |
|---|---|---|
| 1 | Owner accidentally creates 8 sessions on Saturday for an org that’s closed Saturdays. | |
| 2 | POST bulk-preview with daysOfWeek:['saturday']. | Returns matched count + 10 samples. |
| 3 | POST bulk-delete with notifyAffectedMembers:true. | Sessions soft-deleted, active bookings cancelled, batch audit row written, cancellation emails sent. |
Edge cases
E1 — Race for the last slot
| Step | Action | Expected |
|---|---|---|
| 1 | Session has capacity=1, 0 booked. Two members tap Book simultaneously. | One gets confirmed. The other either gets waitlisted (if waitlist available) or BadRequestException('Session is full'). Both transactions retry on 40001 up to 3 times. |
E2 — Booking with no credits
| Step | Action | Expected |
|---|---|---|
| 1 | Member’s remaining_credits=0 on a class-pack plan. | |
| 2 | Tap Book. | 400 ‘No credits remaining’. Booking row not created. |
E3 — Daily / weekly quota hit
| Step | Action | Expected |
|---|---|---|
| 1 | Member already has 2 bookings today on a plan with maxBookingsPerDay=2. | |
| 2 | Book a 3rd session today. | 400 ‘Daily booking limit reached (2 per day)’. |
E4 — Overlap
| Step | Action | Expected |
|---|---|---|
| 1 | Member booked 09:00–10:00. Tries to book 09:30–10:30. Plan disallows overlapping. | 400 ‘You already have a booking that overlaps…’. |
E5 — Late cancel
| Step | Action | Expected |
|---|---|---|
| 1 | Session starts in 30 min. Member tries to cancel. Org cancellationWindowHours=2, allowLateCancellation=false. | 400 with hours-before message. |
| 2 | Owner flips allowLateCancellation=true. Member retries. | Cancel succeeds. No credit refund (verify remaining credits unchanged). |
E6 — Cancel after window with allowLateCancellation=true, then promotion
| Step | Action | Expected |
|---|---|---|
| 1 | Session full; one waitlisted. Window passed but allowLateCancellation=true. Confirmed member cancels. | Booking cancelled with no refund. Waitlisted member auto-promoted with “you’re in” email. |
E7 — Cross-org isolation
| Step | Action | Expected |
|---|---|---|
| 1 | Owner A signs in. POST /organizations/{B}/sessions/{anything}/publish. | 403 ‘Not a member of this organization’ (from requireMembership). |
| 2 | Even with both orgs’ UUIDs known: try to checkIn org-B’s booking via org-A’s session URL. | 404 — service joins through programs.organization_id and rejects. |
E8 — Concurrent waitlist promotion
| Step | Action | Expected |
|---|---|---|
| 1 | Session full; 3 waitlisted (positions 1,2,3). Two confirmed members cancel within ms of each other. | Two promotions occur (positions 1 and 2). All status updates run inside serializable; no double-promotion of the same row. |
E9 — Unpublish guard
| Step | Action | Expected |
|---|---|---|
| 1 | Published session with 3 confirmed bookings. POST /:id/unpublish. | 400 ‘Cannot unpublish a session that has active bookings’. |
| 2 | Cancel all 3 bookings; retry unpublish. | Succeeds; status → draft. |
E10 — Coach trying to publish
| Step | Action | Expected |
|---|---|---|
| 1 | Coach POST /:id/publish. | 403 ‘Only owners and admins can publish sessions’. |
E11 — Inline workout requires session ownership
| Step | Action | Expected |
|---|---|---|
| 1 | Coach in org A POST inline workout on org B’s session. | 404 ‘Session not found’. |
E12 — Bulk-delete excludes booked by default
| Step | Action | Expected |
|---|---|---|
| 1 | 5 sessions match filter; 2 have active bookings. includeBooked not set (default false). | preview: matched=3, withActiveBookings=2. Delete: 3 sessions deleted, 2 untouched. |
| 2 | Re-run with includeBooked:true. | 5 deleted, bookings cancelled, emails sent. |
E13 — TZ correctness on bulk filter startTimes
| Step | Action | Expected |
|---|---|---|
| 1 | Org tz=Asia/Jerusalem. Sessions stored at 07:00 local (UTC stored as 04:00 or 05:00 depending on DST). Bulk filter startTimes=['07:00']. | Matches the 07:00-local sessions regardless of host TZ or DST. |
E14 — Display screen signed URL expires
| Step | Action | Expected |
|---|---|---|
| 1 | Use a display URL > TTL hours old. | 403 ‘Invalid or expired display link’. |
Cross-persona
| Scenario | Setup | Expected |
|---|---|---|
| Coach books on their own session | Coach has membership; books via web | Staff bypass: no credit deduction, no quota check. Booking row has subscription_id=NULL. |
| Member’s two memberships | Member belongs to org A and org B, both with active subs | /bookings/my returns rows across both orgs. |
| Admin cancels then member rebooks | Admin admin-cancels a member’s confirmed booking | Member can re-book the same session (unique constraint fine — cancelled row remains, new row inserted). Verify: TODO — the existing-active-booking check filters by status, but the DB unique is (class_session_id, membership_id) regardless of status. Confirm whether re-book inserts a new row or hits the unique. |
i18n
| Lang | Check |
|---|---|
| en | Cancellation deadline error: “Cancellation window has closed. You can cancel up to {N} hour(s) before class start.” |
| he | Same translation present in he.json under member.cancellation.* / schedule.*. |
| ru | Same in ru.json. |
| All | GPS_TOO_FAR is a stable machine string the client translates to the localized “You are too far from the session location…” key (schedule.checkinPage.tooFar in en.json). |
| RTL | Hebrew calendar grid renders right-to-left; cancellation deadline text aligns to start. |
Expected vs actual checks
- DB: after each test, query
bookingsandclass_sessionsdirectly to confirmstatus,cancelled_at,deleted_at,checked_in_at,checkin_methodmatch expectations. - Audit: bulk operations must leave exactly one
audit_logsrow perbatchIdwithaction ∈ {class_session.bulk_delete, class_session.bulk_publish}and the full filter payload. - Email: assert SES/mailpit log contains one entry per affected member; subject matches the template (e.g. “Class cancelled: Yoga Flow”).
- Push: assert a record was scheduled for T-30 min reminder (BullMQ inspector or push service mock).
- Credits:
subscriptions.remaining_creditsmatches expected after every book/cancel/promotion.