Memberships — QA Plan
Pre-requisites
- Clerk test instance (invitations send a real email; can use a catch-all inbox).
- Org with 1 owner (you’ll need to keep it alive throughout), 1 admin, 1 coach, 2 members. Tier set to
proso member-count limits aren’t a concern unless explicitly tested. - One imported member with
clerk_id=NULL(the “Arbox” persona).
Golden paths
G1 — Owner invites a single member
| Step | Action | Expected |
|---|---|---|
| 1 | Owner POST /:orgId/invitations with {email:"new@x.com", role:"member"}. | 200. DB invitation row, status=pending. Clerk invitation email sent. Shell user + pending_invitation membership inserted. |
| 2 | GET /:orgId/invitations. | Returns the row. |
| 3 | GET /:orgId/members with no filter. | Includes the pending_invitation membership. |
G2 — Invitee signs up
| Step | Action | Expected |
|---|---|---|
| 1 | new@x.com completes Clerk sign-up via the invite link. | Webhook user.created fires. |
| 2 | Webhook runs findOrCreateFromClerk (links the shell user) + acceptPendingInvitations. | Membership flipped to active. Invitation marked accepted. MEMBERSHIP_ACTIVATED { source:'invitation_accepted' } event emitted. |
| 3 | GET /:orgId/members filtered by status=active. | Includes the new member. |
G3 — Bulk-invite imported members
| Step | Action | Expected |
|---|---|---|
| 1 | DB: 3 imported memberships in the org (clerk_id=NULL, no pending invites). | |
| 2 | POST /:orgId/members/bulk-invite with their membershipIds. | 200 {sent:3, skipped:0, failed:0, summary:{total:3,…}}. |
| 3 | Re-run with the same IDs. | 200 {sent:0, skipped:3 (reason:'already_invited'), failed:0}. |
| 4 | Run with a mix of imported + already-Clerk users. | Already-Clerk skipped with reason already_has_account. |
G4 — Resend / revoke
| Step | Action | Expected |
|---|---|---|
| 1 | Owner POST /invitations/:id/resend. | Old Clerk token revoked (best-effort); fresh token + 7-day expires_at on the same DB row. |
| 2 | Owner DELETE /invitations/:id. | Status=revoked; Clerk side revoke attempted. Associated pending_invitation membership flipped to cancelled. |
G5 — Update role
| Step | Action | Expected |
|---|---|---|
| 1 | Owner PATCH /:orgId/members/{adminId} with {role:'coach'}. | 200. Role changed. |
| 2 | Owner PATCH /:orgId/members/{memberId} with {role:'admin'}. | 200. |
| 3 | Admin PATCH /:orgId/members/{otherAdminId} with {role:'member'}. | 200. |
| 4 | Admin PATCH /:orgId/members/{ownerId} with {role:'admin'}. | 200 unless the owner is the last one. |
G6 — Member self stats
| Step | Action | Expected |
|---|---|---|
| 1 | Member with 5 confirmed/attended bookings this month + 8 historical. GET /:orgId/members/me/stats. | {classesThisMonth:5, totalClasses:8}. |
Edge cases
E1 — Last-owner protection
| Step | Action | Expected |
|---|---|---|
| 1 | Only one active owner in org. PATCH that membership with {role:'admin'}. | 403 ‘Cannot change the role of the last owner’. |
| 2 | PATCH with {status:'suspended'}. | 403 ‘Cannot suspend or cancel the owner’. |
| 3 | Promote a second owner first; then demote the original. | Both succeed. |
E2 — Cross-tier invite
| Step | Action | Expected |
|---|---|---|
| 1 | Admin POST invitation with {role:'owner'}. | 403 ‘Only owners can invite owners’. |
| 2 | Coach POST any invitation. | 403 ‘Only owners and admins can invite members’. |
E3 — Tier limit
| Step | Action | Expected |
|---|---|---|
| 1 | Org at lite already at maxMembers. POST invitation with {role:'member'}. | 403 ‘Member limit reached (N/M). Upgrade your plan to add more.’. |
| 2 | Same org, POST invitation with {role:'coach'}. | 200 — staff invites bypass the tier check. |
E4 — Duplicate invite
| Step | Action | Expected |
|---|---|---|
| 1 | Email has a pending invitation in this org. POST again. | 400 ‘A pending invitation already exists for this email’. |
| 2 | Email has an active membership. | 400 ‘User is already a member or has a pending membership’. |
| 3 | Email has a pending_invitation membership but no invitation row (orphan from a partial run). | 400 same as #2 (membership status check). |
E5 — Expired invitation
| Step | Action | Expected |
|---|---|---|
| 1 | Backdate an invitation’s expires_at to yesterday. | |
| 2 | The invitee signs up. acceptPendingInvitations runs. | Invitation flipped to expired. Membership NOT activated. User has no active membership in that org. |
E6 — Webhook + manual accept race
| Step | Action | Expected |
|---|---|---|
| 1 | Webhook calls acceptPendingInvitations while the user simultaneously POSTs /invitations/accept-pending. | Both transactions safe. Membership transitions to active exactly once (existing-active branch is a no-op). |
E7 — Self-delete cascade
| Step | Action | Expected |
|---|---|---|
| 1 | Member with 2 active memberships (orgs A and B) calls DELETE /users/me. | Both memberships → cancelled + deleted_at. Active subs cancelled. |
E8 — Bulk-invite with one already-linked member
| Step | Action | Expected |
|---|---|---|
| 1 | 5 memberships in payload; 1 has clerk_id already. | Result: 4 sent, 1 skipped with already_has_account. |
E9 — Convert lead
| Step | Action | Expected |
|---|---|---|
| 1 | Staff converts a lead in leads-crm. | New memberships row inserted with source_lead_id set. MEMBERSHIP_ACTIVATED { source:'lead_converted' }. |
E10 — requireMembership cache staleness
| Step | Action | Expected |
|---|---|---|
| 1 | Owner suspends a member, then the member immediately retries an org-scoped endpoint. | If cache hit (< 30s old, role unchanged copy), still allowed. After TTL or invalidateMembershipCache(orgId, userId) call, request fails 403. TODO: verify whether updateMembership invokes cache invalidation. |
Cross-persona
- Members can
GET /members(full list). They cannot readmembers/:id/statsof others or PATCH any membership. - Coaches read but cannot mutate any membership row.
- Admins do everything except invite owners and demote/cancel the last owner.
i18n
| Lang | Strings to verify |
|---|---|
| en | members.subtitle reads “Manage your gym members and invitations”. members.invite.*, members.bulkInvite.*. |
| he | Same keys, Hebrew. |
| ru | Same keys, Russian. |
Expected vs actual
- After invitation creation: one
invitationsrow (status=pending), onemembershipsrow (status=pending_invitation), one Clerk invitation reachable via SDK. - After acceptance:
invitations.status='accepted'withaccepted_atset,memberships.status='active',MEMBERSHIP_ACTIVATEDconsumers ran (forms fan-out created compliance form instances, etc.). - After self-delete:
memberships.status='cancelled',deleted_atnon-null on all of the user’s rows. - After bulk-invite: count of
invitationsrows added = sent count from response.