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, promoted_at set. One “You’re in” email and one push. Member-A’s row has cancellation_reason='member'. |
The full waitlist rule set is in Waitlist scenarios (G10–G30).
Automated end to end in the browser by apps/web/e2e/specs/waitlist.spec.ts (make test-e2e-web E2E_ARGS=e2e/specs/waitlist.spec.ts): join on the default unlimited waitlist with rank, queue order and recomputed rank, same-day removal on promotion, capacity raise seating the queue before a newcomer, the one-credit-per-place hold limit, the late-promotion free-cancel grace, the 15-minute cutoff, and the staff roster’s computed ranks.
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. |
Waitlist scenarios
Setup for every scenario below unless it says otherwise. Org timezone Asia/Jerusalem, cancellationWindowHours=2, allowLateCancellation=true, no registration window, booking-policy-rules off. Members: A, B, C, D each on an unlimited monthly plan; P on a class pack with 3 credits; one owner and one coach. Sessions are created by the owner on /dashboard/schedule and published. “Full class” = capacity 1, waitlist size empty (unlimited), with A booked.
DB checks run against the worktree database (DATABASE_URL in apps/api/.env):
SELECT u.first_name, b.status, b.waitlist_position, b.promoted_at, b.cancellation_reason, b.cancelled_at
FROM bookings b JOIN memberships m ON m.id = b.membership_id JOIN users u ON u.id = m.user_id
WHERE b.class_session_id = '<sessionId>' ORDER BY b.created_at;Network checks read GET /organizations/:orgId/sessions/:id (member drawer) and GET /organizations/:orgId/sessions/:id/roster (staff roster) in DevTools.
G10 — Queue order and the rank people see
| Step | Action | Expected |
|---|---|---|
| 1 | Full class 2 days out. B, then C, then D tap Join waitlist. | Each gets “waitlisted”. DB: positions 1, 2, 3. |
| 2 | C opens the class in the member drawer. | ”You’re #2 on the waitlist” (data-testid="waitlist-rank"); response myWaitlistRank: 2, waitlistCount: 3. |
| 3 | B leaves the waitlist. | DB: C still waitlist_position=2, D 3. |
| 4 | C reopens the drawer; D opens it. | C: “#1”; D: “#2”. Response myWaitlistRank 1 / 2. The stored 2 / 3 never shows. |
| 5 | Owner opens the session sheet → Waitlisted group. | Badges #1 (C), #2 (D) (data-testid="waitlist-rank-<bookingId>"); roster response carries waitlistRank 1 / 2. |
| 6 | B joins again. | DB: B waitlist_position=4 (max + 1, not count + 1); B sees “#3”. |
G11 — The waitlist goes before a newcomer
| Step | Action | Expected |
|---|---|---|
| 1 | Full class (A booked), B waitlisted. In SQL, raise capacity without going through the API: UPDATE class_sessions SET capacity = 2 WHERE id = '<sessionId>'; Do this with crons off (local default), or the 5-minute refill (G20 step 3) promotes B first. | B still waitlisted (nothing refilled). |
| 2 | C taps Book. | C gets “waitlisted” (#1 after B’s promotion). DB: B confirmed with promoted_at set, C waitlisted with position 2. B receives the “You’re in” email + push. |
| 3 | API log. | check=waitlist_promoted … trigger=book. |
G12 — A capacity increase fills every new seat
| Step | Action | Expected |
|---|---|---|
| 1 | Full class; B, C, D waitlisted in that order. | |
| 2 | Owner edits the session capacity 1 → 3 and saves. | B and C confirmed (promoted_at set), D still waitlisted, now #1. B and C each get one “You’re in” email and push. |
| 3 | API log. | Two waitlist_promoted … trigger=capacity lines; one seats_filled_from_waitlist event with count: 2. |
| 4 | Owner lowers capacity 3 → 1. | Nobody is cancelled; class shows 3/1 booked. |
G13 — Staff cancel refills and records why
| Step | Action | Expected |
|---|---|---|
| 1 | Full class held by P (pack, 3 → 2 credits); B waitlisted. | |
| 2 | Owner removes P from the roster (admin cancel). | P cancelled, cancellation_reason='staff'; P’s credits back to 3; P’s cancellation email says the credit was refunded. B confirmed, “You’re in” email + push. |
| 3 | Owner opens Cancelled group in the sheet. | P’s row shows “Cancelled by staff”. |
| 4 | Repeat with B cancelling their own seat from the member drawer while C waits. | B’s row reason member (“Cancelled by member”); C promoted (trigger=cancel). |
G14 — A check-in never frees a seat
| Step | Action | Expected |
|---|---|---|
| 1 | Class starting in 1 hour, capacity 1. A booked; B waitlisted. Coach checks A in. | A attended. B still waitlisted; schedule shows 1/1 booked, capacityRemaining: 0. |
| 2 | C taps Book. | C waitlisted (#2). |
| 3 | Coach undoes A’s check-in. | A back to confirmed; no capacity error; B still waiting. |
G15 — No promotion inside 15 minutes or after registration closes
| Step | Action | Expected |
|---|---|---|
| 1 | Full class starting in 12 minutes; B waitlisted. A cancels (late cancel allowed). | A cancelled. B stays waitlisted; no email. The class shows a free seat. |
| 2 | C books it from the schedule. | C confirmed directly: inside the cutoff nobody is promoted, so the free seat goes to whoever books it. B stays waitlisted until the expiry job closes the place (G20). |
| 3 | Full class starting in 60 minutes; B waitlisted. Owner sets “Registration closes 2 hours before”. A cancels. | B stays waitlisted. |
| 4 | Same with a class starting in 3 hours. | B promoted (outside both the cutoff and the close). |
G16 — Who is passed over keeps their place
| Step | Action | Expected |
|---|---|---|
| 1 | Full class; B then C waitlisted. UPDATE subscriptions SET status='paused' WHERE id = '<B sub>'; A cancels. | C promoted. B still waitlisted, still #1. Log check=waitlist_promotion_skipped reason=subscription_inactive. |
| 2 | Restore B (status='active'). C cancels. | B promoted. |
| 3 | Full class; B then C waitlisted. Owner sets B’s membership to Suspended. | B’s waitlist row is cancelled (member_removed, see G23); C remains #1. |
| 4 | booking-policy-rules OFF. B’s plan has maxBookingsPerWeek=1 and B already holds another confirmed class this week. Full class; B then C waitlisted. A cancels. | C promoted; B skipped (weekly_limit), keeps the place. Caps apply with the flag off. |
| 5 | P with 0 credits left (book 3 other classes first) waits first, C second. A cancels. | C promoted; P skipped no_credits. |
| 6 | B’s class pack expired (current_period_end in the past) waits first, C second. A cancels. | C promoted; B skipped credits_expired. |
G17 — Promotion releases the same day’s other waitlists
| Step | Action | Expected |
|---|---|---|
| 1 | Two full classes the same local day, 08:00 and 19:00 (A in 08:00, C in 19:00), and a third full class the next day (D in it). B joins all three waitlists. | |
| 2 | A cancels 08:00. | B confirmed in 08:00. B’s 19:00 row cancelled, cancellation_reason='waitlist_promoted_elsewhere', cancelled_by_user_id NULL. Next-day row untouched. |
| 3 | B’s inbox and phone. | ONE email and ONE push: “You’re in: |
| 4 | B cancels 08:00. | B’s 19:00 place is NOT restored. |
G18 — Free-cancel grace after a late promotion
| Step | Action | Expected |
|---|---|---|
| 1 | Org window 2 hours. Full class starting in 90 minutes (already past the deadline). P (pack) waitlisted. A cancels late. | P confirmed, credits 3 → 2. Email/push: “You’re in … Free cancellation until <promoted + 10 min>“. |
| 2 | P opens the drawer. | ”Free cancel until HH:MM” (data-testid="free-cancel-until"); policy note “You got this place from the waitlist. Cancel before HH:MM to get your credit back.”; response myCancellationState: 'free', myFreeCancelUntil = promotion + 10 min, myPromotedAt set, cancellationDeadline = the same instant. |
| 3 | Within 10 minutes P cancels. | Cancelled; credits back to 3; email says refunded. Event booking_grace_cancel. |
| 4 | Repeat 1, wait 11 minutes, reopen the drawer. | No free-cancel line; button shows the late cancel wording (myCancellationState: 'late_forfeit'). Cancel keeps the credit. |
| 5 | Repeat 1 with allowLateCancellation=false, and wait out the 10 minutes. | During the grace the cancel is enabled; after it the button is disabled (myCancellationState: 'closed'). |
| 6 | A promotion that happens before the deadline (class in 2 days). | No free-cancel line; myFreeCancelUntil: null; normal deadline. |
G19 — One credit per waitlist place on a pack
| Step | Action | Expected |
|---|---|---|
| 1 | P has 1 credit. Two full classes on different days. P joins the first waitlist. | Waitlisted. Credits still 1. |
| 2 | P tries to join the second. | Refused with “You need one available credit for each waitlist place” (code no_credits); log check=waitlist_hold_limit_refused. |
| 3 | P leaves the first waitlist, joins the second. | Allowed. |
| 4 | P with 0 credits tries to join any waitlist. | Refused “No credits remaining”. |
| 5 | B (unlimited) joins three waitlists on three days. | All allowed. |
G20 — Waitlist places close when the class starts
| Step | Action | Expected |
|---|---|---|
| 1 | Full class starting in 20 minutes; B waitlisted. Wait until 5 minutes past the start (the job runs every 5 minutes; locally run WaitlistExpiryService.expireStarted or set CRONS_ENABLED=true). | B cancelled, cancellation_reason='waitlist_expired'. No email or push. Log check=waitlist_expired count=1. |
| 2 | B’s weekly cap count (quota card). | The expired place no longer counts. |
| 3 | Stage G11 step 1 (capacity raised in SQL, B waiting, class 2 days out), then run the job once (handleExpireStarted). | B promoted with trigger=scheduler; “You’re in” email + push. A class starting in under 15 minutes is left alone. |
G21 — Booking guards
| Step | Action | Expected |
|---|---|---|
| 1 | Class that started 5 minutes ago. Member taps Book (or POST /book). | 400 session_started “This class has already started”. Same for the owner booking themselves. |
| 2 | Owner deletes a session; POST /book on its id. | 404 “Session not found”. |
| 3 | Member with NO plan (org allows booking without a plan) books 09:00–10:00, then 09:30–10:30 the same day. | Second refused with the overlap message. |
G22 — Staff booking themselves carry no plan
| Step | Action | Expected |
|---|---|---|
| 1 | The coach also holds a pack. Coach books a full class from the member schedule (waitlisted), then the seat frees. | Coach promoted; subscription_id NULL on the row; the coach’s pack credits unchanged. |
| 2 | Owner books member P into a class from the roster (“book for member”). | P’s pack is charged as before. |
G23 — A member who leaves releases their places
| Step | Action | Expected |
|---|---|---|
| 1 | P holds a confirmed seat in a full class (B waiting) and a waitlist place elsewhere, both in the future, plus a past attended class. | |
| 2 | Owner removes P from the members list (status cancelled). | Both future rows cancelled, member_removed; P’s pack refunded for the confirmed seat; B promoted (“You’re in”). Past attended row untouched. P gets no cancellation email. Log check=member_removed_bookings_cancelled cancelled=2 seatsFreed=1. |
| 3 | Repeat with Suspended instead of removed. | Same. |
| 4 | Repeat with the member deleting their own account. | Same. |
G24 — Session cancel, delete and unpublish
| Step | Action | Expected |
|---|---|---|
| 1 | Class in 1 hour: P confirmed and checked in, B confirmed, C waitlisted. Owner cancels the session. | All three rows cancelled, session_cancelled. P’s credit refunded (attended seat too). C is NOT promoted anywhere. Class-cancelled emails go out. |
| 2 | Same with Delete session and with bulk delete. | Same reasons and refunds. |
| 3 | Published class whose only live row is a waitlisted member (cancel the confirmed one in SQL to stage it). Owner unpublishes. | 400 “Cannot unpublish a session that has active bookings”. |
G25 — Moving a class tells everyone holding a place
| Step | Action | Expected |
|---|---|---|
| 1 | Class in 2 days: A confirmed, B waitlisted. Owner moves it one hour later. | A and B each receive a “Class time changed” email (old → new time; B’s says they are on the waitlist) and push. |
| 2 | A’s reminders. | One T-30 reminder at the new time; the one for the old time does not fire. |
| 3 | A has turned off booking-update emails. Repeat. | A gets no email (push per their push setting). |
G26 — Cancellation emails tell the truth about refunds
| Step | Action | Expected |
|---|---|---|
| 1 | P cancels early. | Email: credit refunded. |
| 2 | P cancels late (allowed, forfeit). | Email: no refund. |
| 3 | B (unlimited) cancels early. | Email does not claim a credit refund (creditRefunded=false: nothing to reverse). |
| 4 | Owner admin-cancels a booking made by staff for themselves. | No refund claim. |
G27 — The member drawer follows the server
| Step | Action | Expected |
|---|---|---|
| 1 | Session with waitlist size empty (unlimited), full. | Drawer offers Join waitlist (waitlistOpen: true). |
| 2 | Waitlist size 1, one already waiting. | Drawer says the class is full, no join button (waitlistOpen: false). |
| 3 | Waitlist size 0. | No waitlist offered. |
| 4 | Confirmed booking past the deadline with late cancel off. | Cancel disabled; note “window closed” (closed). |
| 5 | Same with a booking-policy rule lateCancelCredit: refund (flag on). | Cancel enabled with “Your credit will be refunded” (late_refund). |
| 6 | Set the device clock an hour off and reopen. | States do not change (they come from the server). |
G28 — Analytics and logs
Run G12, G17, G18, G19, G20 and check the API log (Better Stack locally: stdout) and PostHog debug (EventTrackingService logs each track at info outside production).
Event / check | Expected after |
|---|---|
waitlist_promoted with trigger, graceApplied, removedCount | Every promotion |
waitlist_removed_elsewhere | G17 step 2 (one) |
seats_filled_from_waitlist | G12 (count: 2) |
booking_grace_cancel | G18 step 3 |
waitlist_hold_limit_refused | G19 step 2 |
waitlist_expired | G20 |
member_removed_bookings_cancelled (log only) | G23 |
G29 — Spaces report late-cancel rate
| Step | Action | Expected |
|---|---|---|
| 1 | In one space this week: one member late cancel, one staff late cancel, one session cancel inside the window, one cancel inside a promotion grace. | /dashboard spaces report counts 2 late cancels (member + staff), not the session cancel or the grace cancel. |
G30 — Request-invite always emails (retired join-email-ownership-proof)
| Step | Action | Expected |
|---|---|---|
| 1 | Open a public join link, choose “I don’t have an account”, enter an email. | Response { deliveryMode: 'email', email }; no Clerk sign-up URL in the response; the email arrives. Same for every org (no flag). |
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 (withSerializableRetry, up to 32 jittered attempts). |
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)’. |
E3a — Attended and no-show bookings keep their cap slot (TKN-457)
| Step | Action | Expected |
|---|---|---|
| 1 | Member on a plan with maxBookingsPerWeek=2 books Sunday’s class. Staff check them in from the roster. | Booking shows as attended. The member’s quota card shows 1 of 2 used this week. |
| 2 | Member books Monday’s class. | Confirmed. Quota card shows 2 of 2. Tuesday’s class on the schedule is blocked with the weekly-limit message. |
| 3 | Member tries to book Tuesday’s class anyway. | 400 ‘Weekly booking limit reached (2 per week)‘. |
| 4 | Repeat 1–3, but leave Sunday unchecked until the auto no-show job marks it no_show (2 hours after the class ends). | Same as 2–3: the no-show still takes its slot. |
| 5 | Member on maxBookingsPerDay=1 is checked in to the morning class, then tries the evening class the same day. | Evening shows as blocked with the daily-limit message. Booking it returns 400 ‘Daily booking limit reached (1 per day)‘. |
| 6 | Member with an attended Sunday class books Monday’s class, cancels it, and books Wednesday’s class. | Wednesday is confirmed (the cancellation returned Monday’s slot). Quota card shows 2 of 2. |
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…’. |
E4a — Registration window
| Step | Action | Expected |
|---|---|---|
| 1 | Owner sets opens 7 days / closes 30 minutes on the booking settings page and saves. | Saved. Values show again as “7 days” and “30 minutes”. |
| 2 | Member opens a session 10 days away. | Book button replaced by a disabled “Registration opens …” label. A direct POST /book returns 409 registration_not_yet_open with opensAt. |
| 3 | Member opens a session 3 days away. | Booking succeeds. |
| 4 | Member opens a session starting in 20 minutes. | Disabled “Registration closed” label. A direct POST /book returns 409 registration_closed. |
| 5 | Admin books themselves into the session from step 4. | Succeeds: staff bypass the window. |
| 6 | Owner sets closes to 8 days while opens stays 7 days and saves. | Rejected: “Registration must open earlier than it closes”. |
| 7 | Owner clears both fields and saves. | Members can book any published session again. |
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, class more than 15 minutes away. Confirmed member cancels. | Booking cancelled with no refund. Waitlisted member auto-promoted; their “you’re in” message states a free cancel until promotion + 10 minutes (see G18). |
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). Each fill runs inside the cancel’s serializable transaction; no double-promotion of the same row and never more confirmed seats than capacity. |
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. |
| 3 | Session whose only live rows are waitlisted, or attended. POST /:id/unpublish. | 400, same message. |
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’. |
E15 — Bulk writers skip space clashes
Flag spaces on; exclusive space “Main Floor” and shared space “Open Gym”. Single create/edit rows (S1–S14) live in locations QA › Spaces flag.
| Step | Action | Expected |
|---|---|---|
| 1 | Existing “Yoga” Mon 07:00–08:00 in Main Floor. Preview then apply a template with “WOD” Mon 07:30 in Main Floor, conflictMode:'skip'. | Preview and apply both return a conflicts[] group { kind:'space', locationName:'Main Floor', classTypeId:<WOD>, day:'monday', time:'07:30' }. That session is not created; counted in skipped. |
| 2 | Same with conflictMode:'overwrite'. | Yoga untouched; WOD still skipped (kind:'space' is never overwritten). |
| 3 | Same with conflictMode:'abort'. | 409 with the space group; nothing written. |
| 4 | Same template slot in Open Gym (shared). | No space group; session created. |
| 5 | Copy-week where a source session’s space is taken on the target week by another class type. | conflicts[] group kind:'space'; skipped. |
| 6 | Spotter class_sessions.create_many hitting an occupied exclusive space. | That item is in skipped[] with reason:'space_occupied'; others created. |
| 7 | Spotter replicate_day onto a date where a copied session clashes in an exclusive space. | Whole date listed in blockedTargetDates; nothing written for it. |
| 8 | Flag off: repeat steps 1–7. | No space groups; every group is kind:'class_type', locationName:null; no space_occupied. |
E16 — Layered booking rules and entry quotas (flag booking-policy-rules)
Org tz Asia/Jerusalem; halls A, B, Parquet. Automated in booking-policy-victor.int.spec.ts; the steps below are the manual pass.
| Step | Action | Expected |
|---|---|---|
| 1 | Settings → Booking: add “For CrossFit in Parquet, open 1 day before at 20:00”. | The dialog previews the next Parquet CrossFit class and “Booking opens …20:00” before saving. Saved as one sentence row. |
| 2 | Member opens Monday 07:00’s Parquet class on Sunday 19:59, then 20:00. | 19:59: “Registration opens …”; book → 409 registration_not_yet_open. 20:00: books. |
| 3 | Add a second hall rule over halls B + Parquet that also sets booking open. | Refused inline: the conflict names the first rule and “registrationOpens”; nothing saved. |
| 4 | Plan with quotas CrossFit 18 / Open gym 9 / Yoga 0 (window): book 18 CrossFit, then the 19th; a yoga class; a Pilates class. | 19th: “You’ve used all your CrossFit entries” with buy-a-pack / renew-early; yoga: “Your plan doesn’t include this class”; Pilates (no quota names it) books on the plan’s entries. |
| 4b | Plan with weekly limit 5, flag on: open the plan sheet (he, 1440). | One “מכסות כניסה” list; its first row “כל השיעורים”, 5, “לשבוע”, all classes; no separate day/week/month fields and no up/down arrows. Save untouched → the plan still has maxBookingsPerWeek=5. Add a second all-classes weekly row → save refused with the duplicate message. |
| 4c | Plan with quotas “Everything 3” (all classes) and “CrossFit 2” (window): book 3 CrossFit, then a yoga class twice. | 3rd CrossFit refused naming CrossFit (Everything still has room); first yoga books; second yoga refused naming Everything. |
| 5 | Book under a 2h cancel rule, edit the rule to 24h, cancel 3h before. | The booking cancels with the entry back (its snapshot says 2h); a new booking on the class cannot cancel (cancellation_closed). |
| 6 | Flags off: repeat 1–5. | The rule list and quota list are hidden and the plan sheet shows its “Booking rules” section with the three limit fields; every booking behaves as with the org columns only. |
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 (DELETE …/sessions/:sessionId/bookings/:bookingId) | Member can book the same session again: status='confirmed', cancelled_at null, one live booking for that member and session, credit charged again. |
| Member cancels then rebooks, twice | Member books, cancels, books, cancels, books the same session | Every step succeeds; one live booking; credit deducted on each booking and refunded on each in-window cancel. A full class puts the re-booking on the waitlist (no credit spent until promoted). With Fizikal mirroring on, the club holds exactly one vendor seat while booked and none after the last cancel. |
| Re-booking double-fires | After a cancel, the same member sends several book requests at once | Exactly one succeeds; the rest get 400 “Already booked for this session” (never a 500); one live booking; one credit spent. |
All three rows are automated in apps/api/src/bookings/rebook-after-cancel.int.spec.ts (real Postgres; make test-integration-api). The plain cancel → book again path is also covered end to end by the multi-plan-booking Playwright spec and by the prod write smoke journey (apps/web/e2e/prod-write/member-journey.spec.ts).
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_creditsand the ledger sum (SELECT sum(delta) FROM entitlement_ledger WHERE subscription_id = …) match expected after every book/cancel/promotion. - Reasons: every row cancelled during a run has a non-NULL
cancellation_reasonmatching the scenario.