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

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 pro so 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

StepActionExpected
1Owner 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.
2GET /:orgId/invitations.Returns the row.
3GET /:orgId/members with no filter.Includes the pending_invitation membership.

G2 — Invitee signs up

StepActionExpected
1new@x.com completes Clerk sign-up via the invite link.Webhook user.created fires.
2Webhook runs findOrCreateFromClerk (links the shell user) + acceptPendingInvitations.Membership flipped to active. Invitation marked accepted. MEMBERSHIP_ACTIVATED { source:'invitation_accepted' } event emitted.
3GET /:orgId/members filtered by status=active.Includes the new member.

G3 — Bulk-invite imported members

StepActionExpected
1DB: 3 imported memberships in the org (clerk_id=NULL, no pending invites).
2POST /:orgId/members/bulk-invite with their membershipIds.200 {sent:3, skipped:0, failed:0, summary:{total:3,…}}.
3Re-run with the same IDs.200 {sent:0, skipped:3 (reason:'already_invited'), failed:0}.
4Run with a mix of imported + already-Clerk users.Already-Clerk skipped with reason already_has_account.

G4 — Resend / revoke

StepActionExpected
1Owner POST /invitations/:id/resend.Old Clerk token revoked (best-effort); fresh token + 7-day expires_at on the same DB row.
2Owner DELETE /invitations/:id.Status=revoked; Clerk side revoke attempted. Associated pending_invitation membership flipped to cancelled.

G5 — Update role

StepActionExpected
1Owner PATCH /:orgId/members/{adminId} with {role:'coach'}.200. Role changed.
2Owner PATCH /:orgId/members/{memberId} with {role:'admin'}.200.
3Admin PATCH /:orgId/members/{otherAdminId} with {role:'member'}.200.
4Admin PATCH /:orgId/members/{ownerId} with {role:'admin'}.200 unless the owner is the last one.

G6 — Member self stats

StepActionExpected
1Member with 5 confirmed/attended bookings this month + 8 historical. GET /:orgId/members/me/stats.{classesThisMonth:5, totalClasses:8}.

Edge cases

E1 — Last-owner protection

StepActionExpected
1Only one active owner in org. PATCH that membership with {role:'admin'}.403 ‘Cannot change the role of the last owner’.
2PATCH with {status:'suspended'}.403 ‘Cannot suspend or cancel the owner’.
3Promote a second owner first; then demote the original.Both succeed.

E2 — Cross-tier invite

StepActionExpected
1Admin POST invitation with {role:'owner'}.403 ‘Only owners can invite owners’.
2Coach POST any invitation.403 ‘Only owners and admins can invite members’.

E3 — Tier limit

StepActionExpected
1Org at lite already at maxMembers. POST invitation with {role:'member'}.403 ‘Member limit reached (N/M). Upgrade your plan to add more.’.
2Same org, POST invitation with {role:'coach'}.200 — staff invites bypass the tier check.

E4 — Duplicate invite

StepActionExpected
1Email has a pending invitation in this org. POST again.400 ‘A pending invitation already exists for this email’.
2Email has an active membership.400 ‘User is already a member or has a pending membership’.
3Email has a pending_invitation membership but no invitation row (orphan from a partial run).400 same as #2 (membership status check).

E5 — Expired invitation

StepActionExpected
1Backdate an invitation’s expires_at to yesterday.
2The 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

StepActionExpected
1Webhook 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

StepActionExpected
1Member 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

StepActionExpected
15 memberships in payload; 1 has clerk_id already.Result: 4 sent, 1 skipped with already_has_account.

E9 — Convert lead

StepActionExpected
1Staff converts a lead in leads-crm.New memberships row inserted with source_lead_id set. MEMBERSHIP_ACTIVATED { source:'lead_converted' }.

E10 — requireMembership cache staleness

StepActionExpected
1Owner 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 read members/:id/stats of 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

LangStrings to verify
enmembers.subtitle reads “Manage your gym members and invitations”. members.invite.*, members.bulkInvite.*.
heSame keys, Hebrew.
ruSame keys, Russian.

Expected vs actual

  • After invitation creation: one invitations row (status=pending), one memberships row (status=pending_invitation), one Clerk invitation reachable via SDK.
  • After acceptance: invitations.status='accepted' with accepted_at set, memberships.status='active', MEMBERSHIP_ACTIVATED consumers ran (forms fan-out created compliance form instances, etc.).
  • After self-delete: memberships.status='cancelled', deleted_at non-null on all of the user’s rows.
  • After bulk-invite: count of invitations rows added = sent count from response.