Ownware
Home›Membora›Test run
Membora · Test run · run 2026-09-25

Membora Test run, as shipped in the download

230 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 with php tests/run.php. Run it yourself after you download.

== 1. moneyToCents: symbols, US/EU, ints, floats, junk ==
  PASS  "$49.00" (got 4900, want 4900)
  PASS  "59" (got 5900, want 5900)
  PASS  "1,250.00" (got 125000, want 125000)
  PASS  EU "1.250,00" (got 125000, want 125000)
  PASS  EU decimal "12,50" (got 1250, want 1250)
  PASS  int 130 (major) (got 13000, want 13000)
  PASS  float 15.5 (got 1550, want 1550)
  PASS  "0" (got 0, want 0)
  PASS  empty -> null (got NULL, want NULL)
  PASS  "N/A" -> null (got NULL, want NULL)
  PASS  null -> null (got NULL, want NULL)

== 2. centsToDecimal + money formatting ==
  PASS  5900 -> "59.00" (got '59.00', want '59.00')
  PASS  5 -> "0.05" (got '0.05', want '0.05')
  PASS  null -> "" (got '', want '')
  PASS  money 5900 $ (got '$59.00', want '$59.00')
  PASS  money null -> — (got '—', want '—')
  PASS  symbol USD (got '$', want '$')
  PASS  symbol EUR (got '€', want '€')

== 3. date math: daysBetween, addDays, addMonths ==
  PASS  daysBetween same (got 0, want 0)
  PASS  daysBetween +10 (got 10, want 10)
  PASS  daysBetween past (-3) (got -3, want -3)
  PASS  addDays +30 (got '2026-08-04', want '2026-08-04')
  PASS  addDays -1 (got '2026-07-04', want '2026-07-04')
  PASS  addMonths +1 (got '2026-08-15', want '2026-08-15')
  PASS  addMonths +12 (annual) (got '2027-07-15', want '2027-07-15')
  PASS  addMonths clamps Jan31 (got '2026-02-28', want '2026-02-28')
  PASS  monthKey (got '2026-07', want '2026-07')

== 4. effectiveStatus: active stays / lapses, frozen & cancelled hold ==
  PASS  active, future end -> active (got 'active', want 'active')
  PASS  active, end today -> active (got 'active', want 'active')
  PASS  active, end passed -> expired (got 'expired', want 'expired')
  PASS  active, open-ended -> active (got 'active', want 'active')
  PASS  frozen holds past end (got 'frozen', want 'frozen')
  PASS  cancelled holds (got 'cancelled', want 'cancelled')
  PASS  stored expired stays expired (got 'expired', want 'expired')
  PASS  no membership -> none (got 'none', want 'none')

== 5. votingGuard: THE constitution's rule ==
  PASS  in good standing -> vote recorded
  PASS  suspended -> no vote
  PASS  resigned -> no vote
  PASS  status-lapsed -> no vote
  PASS  date-lapsed active -> no vote, WITH the date
  PASS  no subscription at all -> no vote
  PASS  refusal explains they may still attend + count toward quorum
  PASS  life member -> always votes
  PASS  honorary member -> always votes
  PASS  ends today -> still votes (boundary)
  PASS  ended yesterday -> no vote (boundary)

== 7. status transitions ==
  PASS  active -> frozen
  PASS  frozen -> active
  PASS  active -> cancelled
  PASS  active -> expired
  PASS  expired -> active (renew)
  PASS  cancelled -> active (no)
  PASS  cancelled -> anything (no)
  PASS  active -> active (no)

== 8. termDates: what a fresh subscription term looks like per rate ==
  PASS  monthly end = +1 month (got '2026-08-05', want '2026-08-05')
  PASS  annual end = +12 months (got '2027-07-05', want '2027-07-05')
  PASS  life never ends (got NULL, want NULL)
  PASS  honorary never ends (got NULL, want NULL)
  PASS  unknown rate falls back to annual (got '2027-07-05', want '2027-07-05')
  PASS  start date is preserved (got '2026-07-05', want '2026-07-05')
  PASS  Jan 31 +1 month lands in February (got '2026-02-28', want '2026-02-28')

== 9. isExpiring + daysUntil (window detection) ==
  PASS  active ending in 5d -> expiring
  PASS  active ending in 14d (boundary) -> expiring
  PASS  active ending in 15d -> NOT expiring
  PASS  active ending today -> expiring
  PASS  already past end -> NOT expiring
  PASS  frozen -> NOT expiring
  PASS  open-ended (pack) -> NOT expiring
  PASS  daysUntil future (got 14, want 14)
  PASS  daysUntil past (got -4, want -4)
  PASS  daysUntil open (got NULL, want NULL)

== 10. isOverdue + arrearsTotal (boundary at the due date) ==
  PASS  due yesterday unpaid -> overdue
  PASS  due today unpaid -> NOT overdue
  PASS  due tomorrow unpaid -> NOT overdue
  PASS  paid past-due -> NOT overdue
  PASS  arrears sums only overdue-unpaid (got 7400, want 7400)

== 11. revenueInMonth ==
  PASS  revenue July = paid-in-July only (got 20400, want 20400)
  PASS  revenue June (got 5900, want 5900)
  PASS  revenue empty month (got 0, want 0)

== 12. CSV export: headers + RFC-4180 escaping ==
  PASS  members header (got 'Name,Member No,Email,Phone,Joined,Standing,Rate,Start,Ends', want 'Name,Member No,Email,Phone,Joined,Standing,Rate,Start,Ends')
  PASS  CRLF line endings
  PASS  comma/quote name is quoted+escaped
  PASS  member number exported
  PASS  a rate that never ends exports an empty Ends cell
  PASS  payments header (got 'Member,Amount,Due Date,Paid,Paid Date,Method', want 'Member,Amount,Due Date,Paid,Paid Date,Method')
  PASS  paid=1 -> yes
  PASS  paid=0 -> no

== CSV formula-injection guard ==
  PASS  Membership =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  Membership @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  Membership leading-TAB neutralized (got '\'	X', want '\'	X')
  PASS  Membership -text neutralized (got '\'-abc', want '\'-abc')
  PASS  Membership negative number kept (got '-45.00', want '-45.00')
  PASS  Membership plain value untouched (got 'Safe Name', want 'Safe Name')
  PASS  Membership quoting still applies (got '"\'=a,b"', want '"\'=a,b"')

== security hardening: clampCents / safe_back_path / str_clip ==
  PASS  clampCents caps MySQL INT overflow (got 999999999, want 999999999)
  PASS  clampCents floors negative price at 0 (got 0, want 0)
  PASS  clampCents min=1 keeps payments positive (got 1, want 1)
  PASS  clampCents passes a normal price through (got 4999, want 4999)
  PASS  clampCents null stays null (got NULL, want NULL)
  PASS  safe_back_path allows member page (got 'members/12', want 'members/12')
  PASS  safe_back_path allows arrears page (got 'payments/arrears', want 'payments/arrears')
  PASS  safe_back_path blocks absolute URL (got 'payments', want 'payments')
  PASS  safe_back_path blocks protocol-relative (got 'payments', want 'payments')
  PASS  safe_back_path blocks backslash trick (got 'payments', want 'payments')
  PASS  safe_back_path blocks leading slash (got 'payments', want 'payments')
  PASS  safe_back_path blocks null/empty (got 'payments', want 'payments')
  PASS  str_clip clips to column length (got 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', want 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
  PASS  str_clip leaves short strings alone (got 'Fit Club', want 'Fit Club')

== PWA ==
  PASS  manifest.json is valid JSON
  PASS  manifest has required fields
  PASS  manifest URLs are RELATIVE (subpath-install safe)
  PASS  manifest icons exist on disk (192 + 512 + apple-touch)
  PASS  sw.js never caches non-GET
  PASS  sw.js never caches page navigations (auth HTML can not enter cache storage)
  PASS  sw.js cache name carries the version (old caches purged on activate)
  PASS  offline fallback page ships
  PASS  every app layout links the manifest + registers the SW

-- Own It 2.0 --
  PASS  v2/v3: tables
  PASS  v2/v3: totp_cols
  PASS  v2/v3: smtp_cols
  PASS  v2/v3: mint
  PASS  v2/v3: revoke
  PASS  v2/v3: totp
  PASS  v2/v3: recovery
  PASS  v2/v3: delivery
  PASS  v2/v3: rate
  PASS  v2/v3: csv_dry
  PASS  v2/v3: backup_tables
  PASS  v2/v3: attendance_is_one_row_per_member
  PASS  v2/v3: quorum_counts_present_only
  PASS  v2/v3: quorum_follows_a_correction
  PASS  v2/v3: withdrawn_attendance_stops_counting
  PASS  v2/v3: quorum_zero_means_none_required
  PASS  v2/v3: voting_guard_allows_good_standing
  PASS  v2/v3: voting_guard_refuses_lapsed
  PASS  v2/v3: import_preview_matches_commit
  PASS  v2/v3: import_no_partial_write
  PASS  v2/v3: import_preview_writes_nothing
  PASS  v2/v3: import_commit_writes_one
  PASS  v2/v3: tx_skeleton
  PASS  v2/v3: tx_rollback
  PASS  v2/v3: gdpr_map_columns_exist
  PASS  v2/v3: audit_redacts
  PASS  v2/v3: roll_row_matches_the_voting_guard
  PASS  v2/v3: roll_row_carries_rate_and_state
  PASS  v2/v3: attendance_ignores_standing
  PASS  v2/v3: re_marking_updates_never_duplicates
  PASS  v2/v3: a_correction_can_undo_quorum
  PASS  v2/v3: cancelled_meeting_refuses_attendance
  PASS  v2/v3: capacity_of_one_admits_exactly_one
  PASS  v2/v3: apologies_never_take_a_seat
  PASS  v2/v3: roll_json_delegates_never_reimplements
  PASS  v2/v3: roll_search_is_read_only_and_gated
  PASS  v2/v3: decide_refuses_without_quorum
  PASS  v2/v3: vote_asks_the_voting_guard
  PASS  v2/v3: expiry_columns_exist
  PASS  v2/v3: expiry_is_off_until_the_owner_turns_it_on
  PASS  v2/v3: expiry_uses_the_products_own_window_rule
  PASS  v2/v3: expiry_skips_a_member_with_nowhere_to_write
  PASS  v2/v3: expiry_happy_path_actually_reaches_the_member
  PASS  v2/v3: expiry_notice_points_at_the_secretary_not_a_card_form
  PASS  v2/v3: expiry_is_once_per_membership_per_end_date
  PASS  v2/v3: expiry_ledger_refuses_a_duplicate_row
  PASS  v2/v3: expiry_releases_the_slot_when_mail_fails
  PASS  v2/v3: expiry_mail_failure_never_throws
  PASS  v2/v3: expiry_cron_has_no_rule_of_its_own
  PASS  v2/v3: expiry_digest_is_a_separate_switch
  PASS  healthz + api_ping + openapi report 1.0.5
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v3: exactly ONE attendance write exists in the whole product
  PASS  v3: that write lives in record_attendance, inside a transaction, upserting on the unique index
  PASS  v3: every attendance door calls the ONE path (roll call, meeting form, API, MCP)
  PASS  v3: the attendance path never consults standing
  PASS  v3: exactly ONE definition of quorum
  PASS  v3: the shared path fires the attendance webhook, and the DECISION webhook is separate
  PASS  v2: no PHP-8 ternary key re-reads (PLAN item 10)
  PASS  v2: core modules stay dependency-free (no product helpers inside them)
  PASS  v2: dark palette + zero hardcoded white surfaces
  PASS  v2: theme.js + qr.js ship and layout loads theme.js
  PASS  v2: no themed background paired with an un-overridden hardcoded colour (dark-mode trap)
  PASS  v2: dark overrides ship for every pill/alert that hardcodes a light palette
  PASS  v2: palette tokens are WIRED, not just declared (label/chip/hover-bg)
  PASS  v3: Database declares the full core-documented skeleton
  PASS  v3: API.md ships + sw cache is v1.0.5
  PASS  v3: no call to a function this product does not define
  PASS  v3: no sensitive handler is login-only — all are role-gated
  PASS  v3: API-key + webhook + backup handlers require settings.edit
  PASS  v3: both backup doors call the ONE shared redaction list
  PASS  v3: the backup redaction list names every secret column
  PASS  v3: the roll call the dashboard sends you to is in the sidebar
  PASS  v3: with no meeting, the roll call points at the page that creates one
  PASS  C2: the subject-access export takes the same standing as anonymise
  PASS  nav: every sidebar link is highlighted by the page it opens
  PASS  nav: no page highlights a sidebar item that does not exist
  PASS  ip: a direct visitor's forged headers are ignored
  PASS  ip: behind a trusted proxy the forwarded visitor is used
  PASS  ip: CF-Connecting-IP wins over the X-Forwarded-For chain
  PASS  ip: the chain is read right-to-left, past trusted hops
  PASS  ip: a Cloudflare edge is not a trusted proxy by default
  PASS  ip: CIDR maths (v4 boundaries + v6, no cross-family match)
  PASS  scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
  PASS  scope: requireKey reads the key scope and refuses a write on a read-only key
  PASS  scope: mintKey persists the chosen scope, and anything but read is full access
  PASS  scope: /mcp hands the key scope to the core, which refuses write tools per tool
  PASS  scope: the key form lets an administrator mint a read-only key
  PASS  licence: every font ships the SIL OFL text for its own family beside it
  PASS  licence: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause

== F2: the meeting record, minutes afterwards, webhooks that arrive, a club's own words ==
  PASS  meeting record: roll, quorum on the day, tallies, mover/seconder and minutes, as CSV and PDF, from a real database — MEETING-RECORD-OK
  PASS  meeting record: PDF and CSV are routed behind meeting.view; minutes save is routed behind meeting.edit + CSRF
  PASS  meeting record: the meeting's roll page offers the minutes form and both downloads
  PASS  webhooks: the events offered for subscription are exactly the events the app fires (got array (
  0 => 'attendance.refused',
  1 => 'meeting.attendance',
  2 => 'motion.decided',
  3 => 'payment.recorded',
), want array (
  0 => 'attendance.refused',
  1 => 'meeting.attendance',
  2 => 'motion.decided',
  3 => 'payment.recorded',
))
  PASS  webhooks: an endpoint saved under the old name meeting.attended now receives meeting.attendance
  PASS  webhooks: every attendance refusal (cancelled, full, lost the race) fires attendance.refused
  PASS  renewal email: asks the member to reply or speak to the membership secretary, not "at the desk on your next visit"
  PASS  dashboard: the arrears link says what it does ("See arrears"), since nothing is sent to anybody
  PASS  demo ribbon: below 600px it starts as the small pill unless the visitor opened it
  PASS  backups: every non-admin role gets 403 on every backup, restore and backup-token route
  PASS  backups: an administrator still gets the JSON backup (got 'GATE 200', want 'GATE 200')
  PASS  forms: every POST form on a screen reaches a registered route (53 checked)
  PASS  webhooks: mark_paid_marks_it_paid
  PASS  webhooks: mark_paid_sends_payment_recorded
  PASS  webhooks: already_paid_sends_nothing_new
  PASS  webhooks: mark_paid_route_goes_through_the_helper
  PASS  webhooks: mark_paid_emails_the_club
  PASS  backup JSON: an administrator gets a backup, with every credential column planted (10)
  PASS  backup JSON: the download carries none of them
  PASS  backup JSON: the scheduled backup carries none of them
  PASS  backup JSON: the download and the scheduled writer read the one redaction list
  PASS  security page: the backup card says every credential is redacted, not only the SMTP password

————————————————————————————————————————————————————
ALL 230 TESTS PASSED (0 failed)

← Back to Membora · Manual · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →