Gymora · Test run · run 2026-11-07
Gymora Test run, as shipped in the download
400 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 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 & canceled 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 canceled holds (got 'cancelled', want 'cancelled')
PASS stored expired stays expired (got 'expired', want 'expired')
PASS no membership -> none (got 'none', want 'none')
== 5. checkInGuard: THE front-desk rule ==
PASS active unlimited -> allowed, no decrement
PASS frozen -> blocked
PASS canceled -> blocked
PASS status-expired -> blocked
PASS date-lapsed active -> blocked (expired)
PASS no membership -> blocked
PASS class pack w/ credits -> allowed + decrement
PASS class pack w/ 1 credit -> allowed + decrement
PASS class pack at 0 credits -> blocked
PASS day pass on its day -> allowed
PASS day pass day after -> blocked
== 6. creditsAfterCheckIn ==
PASS 5 -> 4 (got 4, want 4)
PASS 1 -> 0 (got 0, want 0)
PASS 0 stays 0 (got 0, want 0)
PASS null (unlimited) (got NULL, want NULL)
== 7. status transitions ==
PASS active -> frozen
PASS frozen -> active
PASS active -> canceled
PASS active -> expired
PASS expired -> active (renew)
PASS canceled -> active (no)
PASS canceled -> anything (no)
PASS active -> active (no)
== 8. termDates: what a fresh term looks like per billing period ==
PASS monthly end = +1 month (got '2026-08-05', want '2026-08-05')
PASS monthly credits null (got NULL, want NULL)
PASS annual end = +12 months (got '2027-07-05', want '2027-07-05')
PASS class pack open-ended (got NULL, want NULL)
PASS class pack credits set (got 10, want 10)
PASS day pass ends same day (got '2026-07-05', want '2026-07-05')
== 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,Email,Phone,Join Date,Status,Plan,Start,Ends,Credits Left', want 'Name,Email,Phone,Join Date,Status,Plan,Start,Ends,Credits Left')
PASS CRLF line endings
PASS comma/quote name is quoted+escaped
PASS null credits render blank
PASS int credits render
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: credit_race
PASS v2/v3: guard_refuses_spent
PASS v2/v3: guard_allows_credit
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: desk_row_matches_the_guard
PASS v2/v3: desk_row_carries_plan_and_credits
PASS v2/v3: desk_checkin_refuses_like_every_other_door
PASS v2/v3: desk_checkin_spends_one_credit
PASS v2/v3: desk_row_knows_they_are_already_in
PASS v2/v3: desk_json_delegates_never_reimplements
PASS v2/v3: desk_search_is_read_only_and_gated
PASS v2/v3: gy_kiosk_delegates_to_the_desk_engine
PASS v2/v3: gy_kiosk_is_rate_limited_like_a_password_door
PASS v2/v3: gy_kiosk_never_confirms_which_codes_exist
PASS v2/v3: gy_kiosk_exit_needs_the_staff_pin
PASS v2/v3: gy_kiosk_is_on_the_kit_public_frame
PASS v2/v3: gy_kiosk_code_field_outweighs_the_kit_field_reset
PASS v2/v3: gy_kiosk_off_page_is_framed_too
PASS v2/v3: gy_kiosk_refuses_word_for_word_like_the_desk
PASS v2/v3: gy_kiosk_cannot_spend_the_last_credit_twice
PASS v2/v3: gy_expiry_columns_exist
PASS v2/v3: gy_expiry_is_off_until_the_owner_turns_it_on
PASS v2/v3: gy_expiry_uses_the_products_own_window_rule
PASS v2/v3: gy_expiry_skips_a_member_with_nowhere_to_write
PASS v2/v3: gy_expiry_happy_path_actually_reaches_the_member
PASS v2/v3: gy_expiry_notice_points_at_the_desk_not_a_card_form
PASS v2/v3: gy_expiry_is_once_per_membership_per_end_date
PASS v2/v3: gy_expiry_ledger_refuses_a_duplicate_row
PASS v2/v3: gy_expiry_releases_the_slot_when_mail_fails
PASS v2/v3: gy_expiry_mail_failure_never_throws
PASS v2/v3: gy_expiry_cron_has_no_rule_of_its_own
PASS v2/v3: gy_expiry_digest_is_a_separate_switch
PASS healthz + api_ping + openapi report 4.1.3
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v3: exactly ONE atomic credit burn exists in the whole product
PASS v3: the burn lives in perform_checkin, guarded by the product's tested rule
PASS v3: every check-in door calls the ONE path (desk, API, MCP, kiosk)
PASS v2: both front doors fire the same webhooks (now from the shared path)
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 color (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 v4.1.3
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: Settings really offers the kiosk PIN the kiosk-off page promises
PASS v3: a write path for kiosk_pin exists and hashes it (kiosk_exit uses password_verify)
PASS C2: the subject-access export takes the same standing as anonymize
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 license: every font ships the SIL OFL text for its own family beside it
PASS license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
PASS demo ribbon: on the public pages it says what the page is, not "signed in as the admin"
PASS demo ribbon: below 600px it starts as the small pill unless the visitor opened it
PASS dashboard: the arrears link says what it does ("See arrears"); nothing is sent to anybody
PASS demo seed: this month and last month have different takings (the card read "$0.00 vs last month")
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 (71 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
== 3.2: member page, class waitlist, billing run, joining online ==
PASS 3.2: w3_book_takes_the_seat_and_refuses_when_full
PASS 3.2: w3_waitlist_queues_in_order
PASS 3.2: w3_a_booked_member_cannot_also_queue
PASS 3.2: w3_a_cancellation_books_the_first_in_line
PASS 3.2: w3_the_promoted_seat_never_exceeds_capacity
PASS 3.2: w3_a_second_cancel_of_the_same_booking_does_nothing
PASS 3.2: w3_a_started_class_cannot_be_booked
PASS 3.2: w3_duplicate_booking_refused
PASS 3.2: w3_the_desk_books_and_cancels_through_the_same_engine
PASS 3.2: w3_portal_token_is_stored_only_as_a_hash
PASS 3.2: w3_portal_finds_its_member
PASS 3.2: w3_portal_refuses_junk_without_a_query
PASS 3.2: w3_a_new_link_kills_the_old_one
PASS 3.2: w3_turn_off_kills_the_link
PASS 3.2: w3_cancel_cutoff
PASS 3.2: w3_only_a_member_the_door_would_let_in_can_book
PASS 3.2: w3_portal_page_shows_the_members_own_record
PASS 3.2: w3_portal_page_shows_nobody_else
PASS 3.2: w3_portal_doors_are_rate_limited_and_never_confirm_a_link
PASS 3.2: w3_portal_booking_uses_the_door_rule
PASS 3.2: w3_portal_hash_leaves_backups_and_anonymise_kills_it
PASS 3.2: w3_billing_run_is_off_until_switched_on
PASS 3.2: w3_billing_renews_on_the_renewal_date_and_writes_the_charge
PASS 3.2: w3_billing_catches_up_a_missed_renewal_without_a_gap
PASS 3.2: w3_billing_leaves_manual_packs_and_long_lapsed_alone
PASS 3.2: w3_billing_counts_what_it_did
PASS 3.2: w3_billing_is_idempotent
PASS 3.2: w3_charge_email_names_the_amount_and_the_owners_link
PASS 3.2: w3_without_a_link_the_member_is_sent_to_the_desk
PASS 3.2: w3_send_reminder_reaches_the_member_with_amount_and_link
PASS 3.2: w3_a_failed_reminder_is_not_counted
PASS 3.2: w3_a_paid_charge_gets_no_reminder
PASS 3.2: w3_one_automatic_reminder_after_n_days
PASS 3.2: w3_the_automatic_reminder_is_sent_once
PASS 3.2: w3_arrears_has_send_reminder
PASS 3.2: w3_billing_cron_has_no_rule_of_its_own
PASS 3.2: w3_join_closed_by_default
PASS 3.2: w3_join_needs_smtp_and_an_online_plan
PASS 3.2: w3_join_needs_an_online_plan
PASS 3.2: w3_join_opens_with_smtp_and_an_online_plan
PASS 3.2: w3_join_refuses_bad_input_and_unoffered_plans
PASS 3.2: w3_join_creates_the_member_and_the_first_charge_but_no_membership_yet
PASS 3.2: w3_join_welcome_email_has_the_pay_link_and_the_member_page
PASS 3.2: w3_join_never_reveals_an_existing_member
PASS 3.2: w3_marking_the_first_charge_paid_starts_the_membership
PASS 3.2: w3_a_second_payment_does_not_start_a_second_membership
PASS 3.2: w3_a_free_plan_starts_at_once
PASS 3.2: w3_join_door_is_rate_limited_with_a_honeypot
PASS 3.2: w3_settings_keep_join_closed_without_smtp_and_the_link_https
PASS 3.2: w3b_messages_are_off_until_switched_on
PASS 3.2: w3b_class_reminder_goes_by_email_and_text_with_the_owners_wording
PASS 3.2: w3b_a_class_reminder_goes_once
PASS 3.2: w3b_the_quiet_member_is_nudged_once
PASS 3.2: w3b_no_second_nudge_for_the_same_quiet_spell
PASS 3.2: w3b_a_member_who_asked_to_stop_gets_nothing
PASS 3.2: w3b_waiver_unknown_until_waiverly_is_connected
PASS 3.2: w3b_waiver_status_comes_from_waiverly_by_email
PASS 3.2: w3b_waiver_answer_is_remembered_for_hours
PASS 3.2: w3b_waiverly_down_means_unknown_never_a_block
PASS 3.2: w3b_the_desk_row_shows_the_remembered_waiver_without_waiting
PASS 3.2: w3b_the_signing_link_is_prefilled
PASS 3.2: w3b_welcome_carries_the_waiver_signing_link
PASS 3.2: w3b_check_in_warns_about_the_waiver_but_never_refuses
PASS 3.2: w3b_keys_are_write_only_and_left_out_of_backups
PASS 3.2: w3b_members_can_stop_messages_from_their_page_and_cron_sends
PASS 3.2: w3o_a_trial_lasts_its_days
PASS 3.2: w3o_an_ended_trial_becomes_the_named_plan_the_same_day
PASS 3.2: w3o_the_first_charge_is_written_due_that_day
PASS 3.2: w3o_a_trial_with_auto_renew_off_just_ends
PASS 3.2: w3o_a_trial_rolls_over_once
PASS 3.2: w3o_the_plan_form_only_rolls_into_a_monthly_or_annual_plan
== card payments (Stripe, the owner's own account) ==
PASS card payments: card_off_by_default
PASS card payments: card_ready_needs_the_switch_both_keys_and_a_cents_currency
PASS card payments: card_signature_checks_hmac_secret_body_and_age
PASS card payments: card_signature_is_over_the_raw_body_and_a_bad_one_writes_nothing
PASS card payments: card_checkout_asks_stripe_for_exactly_the_charge
PASS card payments: card_refuses_someone_elses_charge_and_unoffered_plans
PASS card payments: card_the_redirect_alone_records_nothing
PASS card payments: card_webhook_marks_the_charge_paid_exactly_once
PASS card payments: card_webhook_before_the_redirect_shows_paid
PASS card payments: card_join_charge_starts_the_membership_only_on_the_webhook_once
PASS card payments: card_class_pack_credits_are_added_once
PASS card payments: card_monthly_extends_from_the_later_of_today_and_the_end_date
PASS card payments: card_a_day_pass_starts_today
PASS card payments: card_a_replayed_event_id_is_dropped_before_it_is_read
PASS card payments: card_wrong_currency_amount_or_session_records_nothing
PASS card payments: card_a_delayed_payment_is_recorded_when_stripe_confirms_it
PASS card payments: card_an_expired_checkout_is_closed_and_charges_nothing
PASS card payments: card_a_stripe_error_is_a_plain_message_and_a_failed_row
PASS card payments: card_a_payment_after_the_desk_marked_it_paid_is_flagged_for_refund
PASS card payments: card_a_failed_recording_answers_500_and_the_retry_records_it_once
PASS card payments: card_keys_are_left_out_of_backups
PASS card payments: card_routes_and_doors
PASS card payments: card_member_page_offers_pay_and_buy_only_the_online_plans
PASS card payments: card_charge_email_points_to_card_payment_on_the_member_page
== fix wave: every REST write checks the key holder's role ==
PASS api: a viewer's key is refused POST /api/members (403, needs member.edit)
PASS api: a member's key passes the role check on POST /api/members
PASS api: a admin's key passes the role check on POST /api/members
PASS api: a viewer's key is refused POST /api/checkins (403, needs checkin.create)
PASS api: a member's key passes the role check on POST /api/checkins
PASS api: a admin's key passes the role check on POST /api/checkins
PASS api: a viewer's key is refused POST /api/members/{id}/payments (403, needs payment.record)
PASS api: a member's key passes the role check on POST /api/members/{id}/payments
PASS api: a admin's key passes the role check on POST /api/members/{id}/payments
PASS api: every /api/* write route checks the key holder's role (no key-only writes)
PASS backup JSON: an administrator gets a backup, with every credential column planted (18)
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
————————————————————————————————————————————————————
== 4.0: the Update button is wired ==
PASS updates: the release public key is pinned
PASS updates: Database::migrate() creates the updater table (in code, not in a comment)
SKIP updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
PASS updates: index.php clears stale compiled code before anything else loads
PASS updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
PASS updates: route get /updates is updates_page()
PASS updates: route post /updates/key is updates_key_save()
PASS updates: route post /updates/check is updates_check()
PASS updates: route post /updates/apply is updates_apply()
PASS updates: updates_page() opens with the restore page's admin gate
PASS updates: updates_key_save() opens with the restore page's admin gate
PASS updates: updates_check() opens with the restore page's admin gate
PASS updates: updates_apply() opens with the restore page's admin gate
PASS updates: every POST checks the CSRF token
PASS updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
PASS updates: the menu links to the page
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
PASS updates: with a key and a newer signed release the page offers "Back up and update"
== 4.0 A2: sample data, backups, health ==
PASS A2: the owner's-install checks ran to the end
PASS A2: an owner's own install (no Cloud config, no demo marker)
PASS A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
PASS A2: switched off: an install without "Start with sample data" gets none
PASS A2: first request: the marker is used once and the sample business is there
PASS A2: every one of the owner's settings is theirs, not the sample's
PASS A2: the sample logins cannot sign in: no password matches, and switched off where the product can
PASS A2: the owner's own login is untouched
PASS A2: the load is in the audit log
PASS A2: a second marker on a Gymora with records seeds nothing and changes nothing
PASS A2: real use: one of the owner's own rows among the sample
PASS A2: remove: the owner's own row stays, and a sample row it points at is kept
PASS A2: remove: the report says what went, and what was kept because the owner's records use it
PASS A2: remove: the rest of the sample is gone
PASS A2: remove: every kept sample row keeps all its own parts
PASS A2: remove: no row is left pointing at a row that was removed
PASS A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
PASS A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
PASS A2: remove: the owner's own uploaded file is untouched
PASS A2: "Load demo data" on a Gymora with records refuses and deletes nothing
PASS A2: backup: written, sent to the bucket (a signed PUT), and read back
PASS A2: backup: the bucket secret is not in the backup it protects
PASS A2: backup: the owner's "keep" count is honored (2 kept)
PASS A2: switched off: with automatic backups off, a backup is never due however old the last one
PASS A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
PASS A2: an offsite endpoint must be https for an owner (plain http only in tests)
PASS A2: all 25 owner's-install checks reported
PASS A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
PASS A2: index.php loads the three cores and the wiring
PASS A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
PASS A2: route post /sample-data/remove is sample_data_remove()
PASS A2: route post /restore/backups is backups_save()
PASS A2: route post /restore/backup-now is backup_now()
PASS A2: route get /health is health_page()
PASS A2: sample_data_remove() opens with the restore page's admin gate
PASS A2: backups_save() opens with the restore page's admin gate
PASS A2: backup_now() opens with the restore page's admin gate
PASS A2: health_page() opens with the restore page's admin gate
PASS A2: every POST checks the CSRF token
PASS A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
PASS A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
PASS A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
PASS A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
PASS A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Gymora, removable)
PASS A2: the menu links to Health
PASS A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
PASS A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
SKIP A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
PASS erase planting · sqlite: the subject is created and a marker is planted in all 6 personal columns
PASS erase planting · sqlite: the app's own erase runs through its route and reports no failure
PASS erase planting · sqlite: no planted marker survives anywhere (28 tables dumped)
PASS erase planting · sqlite: the member page offers Anonymize (the route that keeps the books) and says Remove deletes the history
ALL 400 TESTS PASSED (0 failed)
← Back to Gymora · Manual · Quickstart · API