Test Plan: Membership & Profiles

♻️ Reconciled against current code — 2026-07-01. Corrections vs. prior version: full MemberStatus model; status changes go through the Actions ▾ → Change Status modal (not per-row buttons); the "pending" seed user is a prospect (approve via the application path, not the member-approve path); approval-workflow settings moved to /admin/members/settings?tab=workflow. Key correction this pass: the status-transition engine is now the single canonical source of truth — every activation path (approval skip-dues, dues-invoice-paid, admin reactivate) computes expiry via the same computeMembershipExpiry() helper in services/membership/membershipStatus.service.ts, so the earlier OQ-RET-16 caveat ("reactivate doesn't set a new expiry") is superseded — see the green banner below for the full #447/#453 rewrite. MembershipPeriod now has 4 values: MONTHLY, QUARTERLY, ANNUAL, LIFETIME.
🆕 New on stage — 2026-06-19 → 2026-07-01.

1. Scope

  1. Profile Management — member self-service (details, avatar) + admin edit (incl. the "since/joined" date, #184).
  2. Member Directory — approved-member search/listing.
  3. Admin Member Controls — status changes (suspend / reactivate / deactivate), role assignment, audit.
  4. Approval Workflow — pending applications (approve / reject) and the prospect pipeline.
  5. Registration & Prospects — self-service registration (incl. tenant-configurable fields #185) + invitations.
  6. Membership Types — type CRUD.

2. Member status model

MemberStatus values (current): ACTIVE, INACTIVE, SUSPENDED, PENDING_APPROVAL, PENDING_PAYMENT, EXPIRED, REJECTED, CANCELLED, GUEST_ATTENDEE (legacy PENDING_VERIFICATION still handled). Prospect pipeline uses a separate prospectStatus (NONE→INTERESTED→INVITED→APPLIED→UNDER_REVIEW→APPROVED/DECLINED) and membershipApplicationStatus (NOT_APPLIED / PENDING / APPROVED / REJECTED).

Since #447/#453, all transitions below are enforced by services/membership/membershipStatus.service.ts — each function loads the member's current status and checks it against an explicit allow-list ("source-state guard") before writing. An illegal call returns success:false with a message like Cannot suspend member with status X instead of silently no-op'ing or corrupting state.

TransitionTriggerAllowed FROM statuses (guard)
(register) → PENDING_APPROVAL / PENDING_PAYMENT / ACTIVESelf-registration, depending on tenant approval + auto-dues config
PENDING_APPROVALPENDING_PAYMENT or ACTIVEapproveMembership() — admin approve (member detail inline Approve); ACTIVE path sets membershipExpiresAt via computeMembershipExpiry()PENDING_APPROVAL only (prospect flow extends with GUEST_ATTENDEE)
PENDING_APPROVALREJECTEDrejectMembership() — admin rejectPENDING_APPROVAL only
PENDING_PAYMENTACTIVEonDuesInvoicePaid() — DUES invoice reaches PAID (online or manual-approved, #527). Sets membershipStartDate + membershipExpiresAt via computeMembershipExpiry(invoice.duesPeriod, now, period)Invoice must be source:'DUES' and status:PAID
ACTIVE / INACTIVE / EXPIRED / PENDING_PAYMENTSUSPENDEDsuspendMembership() — Actions ▾ → Change Status (reason required). Atomically bumps User.tokenVersion + revokes sessions + sends notification emailACTIVE, INACTIVE, EXPIRED, PENDING_PAYMENT (already-SUSPENDED, PENDING_APPROVAL, REJECTED, CANCELLED, GUEST_ATTENDEE all rejected)
ACTIVEINACTIVEdeactivateMembership() — bumps User.tokenVersion atomically (#446) → next request 401ACTIVE only
SUSPENDED / EXPIRED / INACTIVEACTIVEreactivateMembership() — Actions ▾ → Change Status → Reactivate. Sets membershipExpiresAt via the same computeMembershipExpiry() call as approval/dues-paidSUSPENDED, EXPIRED, INACTIVE
ACTIVEEXPIREDexpireMembership() — membership-expiry cronACTIVE only

2a. Canonical expiry formula — computeMembershipExpiry(duesPeriod, from, membershipPeriod)

Single function, called identically by all three activation paths above (approval skip-dues, dues-invoice-paid, admin reactivate) — same inputs always produce the same expiry date, regardless of path:

ConditionResult
membershipPeriod === "LIFETIME"null — no finite expiry, checked first (takes priority over duesPeriod)
membershipPeriod === "QUARTERLY"from + exactly 3 calendar months
Otherwise, duesPeriod set (e.g. "2025" or "2024-2025")Dec 31, 23:59:59.999 of the trailing 4-digit year parsed from duesPeriod
Otherwise (no duesPeriod, not QUARTERLY/LIFETIME — e.g. MONTHLY/ANNUAL with no period string)from + 1 year (flat duration fallback)
OQ-RET-16 / #151 — SUPERSEDED by #447/#453 (2026-06-25): the earlier fix (admin Reactivate sets ≈ now+1yr flat) has been replaced by the canonical computeMembershipExpiry() call above — reactivate now honours the member's actual MembershipPeriod (LIFETIME → null, QUARTERLY → +3mo, else duesPeriod-year-end or +1yr fallback) instead of a hardcoded +1yr. Test: assert the reactivated expiry matches the formula in §2a for the member's period, not a blanket "+1 year".

3. Key routes & UI

AreaRoute / how to reach
All Members/admin/members — list + split-view detail panel (URL ?selected=<id>). Sidebar: Members → All Members.
Member full edit (incl. joined date)/admin/members/:id/edit"Edit Profile" button (ADMIN only). "Member Since (Joined Date)" field (#184).
Status change / roles / membership typeMember detail header → "Actions ▾" → Change Status / Manage Roles / Change Membership Type.
Prospects pipeline/admin/prospects"Add Prospect", invite, approve applications.
Approval-workflow settings/admin/members/settings?tab=workflow (legacy /admin/settings/approval-workflow redirects here).
Registration / custom fields config/admin/members/settings?tab=fields (#185).
Self-service registration/register (public).
Member directory/directory — approved MEMBER/ADMIN/OFFICER/MEMBERSHIP_MANAGER only.
Member own profile/account/profile (avatar upload here, #199).
Recent-signups widget (#186)Executive Dashboard /admin/dashboard — only shows when signupNotifEnabled is on.
No "Add Member" button. Member intake is via the Prospects pipeline (Add Prospect / invite / approve application) or self-service /register. Legacy /admin/members/add, /new, /invite all redirect to /admin/prospects.

4. Test data (RCME seed) — exact statuses

UserEmail / PasswordRoleMemberStatus
Maria Cruztestmember@rcme.membervu.com / Member123!MEMBERACTIVE
Antonio Dela Cruzexpired@demo.membervu.com / Expired123!EXPIRED_MEMBEREXPIRED
Roberto Mendozaguest@demo.membervu.com / Guest123!GUESTACTIVE (role GUEST, not GUEST_ATTENDEE)
Miguel Fernandez (prospect)pending@demo.membervu.com / Pending123!GUESTGUEST_ATTENDEE · prospectStatus=APPLIED · application=PENDING
Isabella Ramospending-payment@demo.membervu.com / Payment123!PENDING_PAYMENTPENDING_PAYMENT (unpaid dues)
Elena Torresmembershipchair@demo.membervu.com / Membership123!MEMBERSHIP_MANAGERACTIVE
Adminadmin@rcme.membervu.com / Admin123!ADMINACTIVE
Critical: pending@demo.membervu.com (Miguel) is a prospect/applicant with status GUEST_ATTENDEE, not PENDING_APPROVAL. The member-approve action (POST /members/:id/approve) will reject him ("only PENDING_APPROVAL members can be approved"). Approve him via the application path on /admin/prospects (POST /admin/applications/:id/approve). There is no seeded PENDING_APPROVAL user — create one via /register when a true approval test is needed.

5. This-week changes folded in (stage = dev)

6. Test Cases

TCTopicDetailed
TC-MEM-001Profile Management (self + admin edit)detailed
TC-MEM-002Member Directorydetailed
TC-MEM-003Admin Member Controlsdetailed
TC-MEM-004Approval Workflow & Prospectsdetailed
TC-MEM-005Registration & Guests/Prospectsdetailed
TC-MEM-007Membership Types CRUDStatus Transitions (detailed)
TC-MEM-008🆕 Membership Periods & Status Engine (QUARTERLY/LIFETIME, unified transitions, single expiry calc, manual-dues activation)detailed