Loyalora · Test run · run 2026-09-25
Loyalora Test run, as shipped in the download
281 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.
[DB: SQLite]
== 1. parse_cents: money a HUMAN typed, in string maths ==
PASS "19.99" (got 1999, want 1999)
PASS "0.01" (got 1, want 1)
PASS "12" (no decimal) (got 1200, want 1200)
PASS ".5" (got 50, want 50)
PASS "12." (trailing dot) (got 1200, want 1200)
PASS a third decimal truncates, never rounds up (got 199, want 199)
PASS US grouping "1,234.56" (got 123456, want 123456)
PASS EU grouping "1.234,56" (got 123456, want 123456)
PASS spaces as grouping "1 234,56" (got 123456, want 123456)
PASS a lone separator is the DECIMAL point ("1,234") (got 123, want 123)
PASS and the same in the other convention ("1.234") (got 123, want 123)
PASS so "1,50" is one fifty (got 150, want 150)
PASS and "0.500" is fifty cents (got 50, want 50)
PASS and a three-decimal price is NOT read as thousands (got 599, want 599)
PASS negative (got -550, want -550)
PASS junk is NULL, not zero — "you typed something odd" is not "that is worth nothing" (got NULL, want NULL)
PASS empty is NULL (got NULL, want NULL)
PASS a lone separator is NULL (got NULL, want NULL)
== 2. pointsForSpend: integer arithmetic that always rounds DOWN ==
PASS $24.99 at 1/unit -> 24 (got 24, want 24)
PASS $25.00 at 1/unit -> 25 (got 25, want 25)
PASS $0.99 at 1/unit -> 0 (got 0, want 0)
PASS a zero spend earns nothing (got 0, want 0)
PASS a negative spend earns nothing (got 0, want 0)
PASS $25.00 at 0.5/unit -> 12 (rounds DOWN, never to 13) (got 12, want 12)
PASS $1.99 at 0.5/unit -> 0 (got 0, want 0)
PASS $24.99 at 10/unit -> 249 (got 249, want 249)
PASS a zero rate earns nothing, and does not divide by zero (got 0, want 0)
PASS a stamps programme earns nothing from a spend (got 0, want 0)
PASS a points programme earns nothing from a visit (got 0, want 0)
PASS one visit, one stamp (got 1, want 1)
PASS three visits, three stamps (got 3, want 3)
PASS three stamps a visit, two visits -> 6 (got 6, want 6)
PASS negative visits earn nothing (got 0, want 0)
PASS a very large spend at a high rate stays a believable integer
== 3. ⭐ balance is a SUM — there is no balance column ==
PASS a customer with no entries has a zero balance (got 0, want 0)
PASS 100 + 50 - 30 - 20 = 100 (got 100, want 100)
PASS the SQL sum and a PHP sum over the same rows agree
PASS no table anywhere carries a balance column
PASS the first card is unchanged (got 100, want 100)
PASS the second card has its own (got 7, want 7)
PASS and the total is both (got 107, want 107)
PASS balanceByProgram splits them the same way
PASS balances() gets every customer in ONE query and agrees with balance()
PASS balances(programId) narrows to that programme
== 4. ⭐ redeem: the check and the write are ONE step ==
PASS a reward beyond the balance is refused
PASS and the refusal names both numbers
PASS the refused redemption wrote NOTHING (got 250, want 250)
PASS not even an entry (got 1, want 1)
PASS not even a redemption row (got 0, want 0)
PASS an affordable reward succeeds
PASS the balance drops by exactly the cost (got 150, want 150)
PASS the returned balance matches the ledger (got 150, want 150)
PASS it wrote ONE redeem entry (got 1, want 1)
PASS and ONE redemption row (got 1, want 1)
PASS the redemption points at the exact entry that spent the points
PASS the redeem entry is NEGATIVE (got -100, want -100)
PASS a reward costing EXACTLY the balance succeeds
PASS and lands on zero (got 0, want 0)
PASS the same reward at zero is then refused
PASS and zero is still zero (got 0, want 0)
PASS a retired reward cannot be redeemed
PASS a reward with no cost is refused rather than redeemed for nothing
PASS a reward that does not exist is refused
PASS a customer that does not exist is refused
PASS after every refusal the balance is untouched (got 500, want 500)
== 5. adjust: the correction path, held to the same floor ==
PASS an adjustment without a reason is refused
PASS an adjustment of zero is refused — it would say nothing
PASS neither wrote anything (got 100, want 100)
PASS a positive adjustment lands
PASS and moves the balance (got 150, want 150)
PASS a negative adjustment lands
PASS and moves it back (got 100, want 100)
PASS an adjustment that would go below zero is refused
PASS and the refusal names the balance
PASS the refused adjustment wrote nothing (got 100, want 100)
PASS an adjustment straight TO zero is allowed
PASS and lands on zero (got 0, want 0)
== 6. entries are IMMUTABLE ==
PASS no code path anywhere UPDATEs or DELETEs an entry
PASS there is exactly ONE writer of entries — Loyalty::addEntry
PASS an unknown entry kind falls back to adjust rather than being stored as junk
== 7. affordable(): what they can have, and what the next one needs ==
PASS two are affordable at 120 (got 2, want 2)
PASS the dearest affordable is listed first (got 100, want 100)
PASS the nearest out of reach is the next one (got 'C', want 'C')
PASS and it names exactly how many short (got 130, want 130)
PASS at zero nothing is affordable (got 0, want 0)
PASS and the next one is the cheapest (got 'A', want 'A')
PASS short by its full cost (got 50, want 50)
PASS a big balance affords everything (got 4, want 4)
PASS and there is no next (got NULL, want NULL)
PASS a balance EXACTLY equal to a cost affords it (got 2, want 2)
PASS no rewards at all is not a crash (got 0, want 0)
== 8. running balance: how the number got here ==
PASS history comes back newest-first (got 10, want 10)
PASS and the newest running balance is the current balance (got 130, want 130)
PASS the oldest row shows the first entry alone (got 100, want 100)
PASS the running balance never skips a step (got 120, want 120)
PASS every step equals the one before it plus that entry
PASS history is ordered by WHEN IT HAPPENED, not by which row was written first
PASS and the running balance follows that same order
== 9. labels: the register says what it does, in words ==
PASS 1 point per unit (got '1 point per unit spent, rounded down', want '1 point per unit spent, rounded down')
PASS half a point (got '0.5 points per unit spent, rounded down', want '0.5 points per unit spent, rounded down')
PASS ten points (got '10 points per unit spent, rounded down', want '10 points per unit spent, rounded down')
PASS one stamp a visit (got '1 stamp per visit', want '1 stamp per visit')
PASS three a visit (got '3 stamps per visit', want '3 stamps per visit')
PASS the default name for a points programme (got 'point', want 'point')
PASS and for a stamps programme (got 'stamp', want 'stamp')
PASS an operator name wins (got 'star', want 'star')
PASS singular at one (got 'point', want 'point')
PASS plural at zero (got 'points', want 'points')
PASS plural at two (got 'points', want 'points')
== 10. csv_cell: numeric-aware formula-injection guard ==
PASS a leading = is neutralised (got '\'=1+1', want '\'=1+1')
PASS a leading + is neutralised (got '\'+cmd', want '\'+cmd')
PASS a leading @ is neutralised (got '\'@SUM', want '\'@SUM')
PASS a NEGATIVE NUMBER is left alone — every redemption in this ledger is one (got '-500', want '-500')
PASS a hyphenated word is still neutralised (got '\'-cmd', want '\'-cmd')
PASS ordinary text is untouched (got 'Mira Halvorsen', want 'Mira Halvorsen')
PASS a comma is quoted (got '"a,b"', want '"a,b"')
PASS a quote is doubled (got '"say ""hi"""', want '"say ""hi"""')
== 11. csrf_valid: the empty-token bypass (hash_equals('','') is TRUE) ==
PASS an unset session token can never match
PASS an unset session token rejects a supplied one
PASS a real token matches itself
PASS a real token rejects a wrong one
== 12. Pdf: produces a valid, non-trivial document ==
PASS starts with the PDF magic
PASS ends with the EOF marker
PASS is not a stub
PASS page placeholders are resolved, never printed literally
== 13. roles: the rung that matters is redeem vs adjust ==
PASS the ladder has one definition (got array (
0 => 'viewer',
1 => 'clerk',
2 => 'manager',
3 => 'admin',
), want array (
0 => 'viewer',
1 => 'clerk',
2 => 'manager',
3 => 'admin',
))
PASS a viewer reads and writes nothing
PASS a clerk earns and redeems at the counter
PASS ⭐ but a clerk may NOT adjust — the only entry that creates points from nothing
PASS a manager may adjust, and edit the scheme
PASS a manager may NOT reach settings, the team or the API
PASS an admin may do everything
PASS an unknown role gets nothing
PASS every rung survives being normalised through the product list
PASS ⭐ a normalize WITHOUT the product list would silently lose the clerk rung
PASS no caller normalises without the product list
PASS the last admin cannot be demoted
== 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
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 the layout links the manifest + registers the SW
== client_ip ==
PASS a direct visitor's forged headers are ignored
PASS behind a trusted proxy the forwarded visitor is used
PASS CF-Connecting-IP wins over the X-Forwarded-For chain
PASS the chain is read right-to-left, past trusted hops
PASS a Cloudflare edge is not a trusted proxy by default
PASS CIDR maths (v4 boundaries + v6, no cross-family match)
== ⭐ concurrency (real parallel processes) ==
PASS the balance starts at exactly 300 (three redemptions' worth)
raced 6 processes: 3 succeeded, 3 refused for lack of points, 0 other
PASS exactly three redemptions succeeded — not four, not two
PASS every other racer was refused for lack of points, not by an error
PASS the balance landed on exactly zero
PASS the balance NEVER went below zero
PASS one redeem entry per success
PASS one redemption row per redeem entry
PASS the points spent equal the points that existed
PASS one point short: not one of four racers got through
PASS and the short balance is untouched
PASS a negative adjustment cannot take a balance below zero
PASS the refused adjustment wrote nothing
PASS the concurrency proof ran and passed
-- Own It 2.0 --
PASS v2: tables
PASS v2: totp_cols
PASS v2: smtp_cols
PASS v2: notify_defaults_off
PASS v2: loyalty_tables
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: csv_dry
PASS v2: backup_tables
PASS v2: upgrade_from_1x
PASS v2: serializer_matches_core
PASS v2: entries_survive_a_customer_anonymise
-- Own It 3.0 --
PASS v3: v3_tables
PASS v3: v3_columns
PASS v3: upgrade_restores_v3_tables
PASS v3: migrate_ensures_the_settings_singleton
PASS v3: mcp_card_matches_the_engine
PASS v3: mcp_refuses_an_unknown_customer
PASS v3: every_read_tool_is_annotated_readOnly
PASS v3: write_tools_are_not_annotated_readOnly
PASS v3: a_read_only_key_still_sees_the_read_tools
PASS v3: mcp_write_hits_the_same_refusal
PASS v3: mcp_write_actually_writes
PASS v3: mcp_has_no_adjust_tool
PASS v3: mcp_cannot_set_a_points_figure
PASS v3: customer_created_fires
PASS v3: points_earned_fires
PASS v3: reward_redeemed_fires
PASS v3: brand_css_brands_the_light_theme
PASS v3: brand_css_brands_the_dark_theme_too
PASS v3: brand_dark_accent_is_legible_on_the_dark_canvas
PASS v3: unbranded_install_emits_no_brand_css
PASS v3: scheduled_backup_writes_a_dated_file
PASS v3: scheduled_backup_redacts_credential_material
PASS v3: scheduled_backup_prunes_to_the_retention_count
PASS v3: a_dry_run_writes_nothing
PASS v3: the_report_and_the_import_agree_exactly
PASS v3: the_import_wrote_exactly_what_it_promised
PASS v3: a_bound_int_threshold_would_have_matched_nothing
PASS v3: the_panel_never_compares_a_bound_string_to_a_sum
PASS v3: points_are_never_measured_against_another_programmes_reward
PASS v3: gdpr_export_carries_the_ledger
PASS v3: anonymise_blanks_the_person_and_keeps_the_points
PASS v3: audit_records_the_writes
PASS v3: audit_detail_carries_no_secret
PASS v3: db_files_cleaned
== wiring ==
PASS every route has a function to answer it
PASS every function the controllers CALL actually exists
PASS every view the controllers render exists on disk
PASS no view posts to a route that does not exist
PASS no POST route shares a path with a real directory
PASS index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
PASS healthz answers BEFORE the installer redirect, on a bare box and a schemaless one
PASS 2FA intercepts login + login is rate-limited
PASS the API reuses the tested core (never re-derives a balance)
PASS no ternary key re-reads (PLAN item 10)
PASS a screen that renders a credential is gated on READ, not only on write
PASS every UI mutation is role-gated, not merely logged in
PASS no ungated write route beyond the by-design baseline (router-derived)
== UI kit ==
PASS layout loads the kit css+js, groups the nav (>=3 groups), keeps the theme toggle and a who-line
PASS style.css is an app layer with a dark accent, and no theme.js survives
PASS no font or script is fetched from another host (fonts bundled)
PASS every structured empty state uses an icon the kit actually masks
PASS no shipped asset is dead weight — every js/css file is actually loaded
PASS every table uses the kit's WRAPPER contract, not class="tbl" on the table itself
PASS no view leans on a CSS class the app layer never defines
== the hero + the honest limits ==
PASS hero: the card shows the balance, what they can afford, and what the next one needs
PASS hero: an affordable reward is redeemable in place, behind a confirm
PASS hero: the full immutable history is on the same page, with a running balance
PASS hero: the earn form asks what HAPPENED, never a points figure
PASS hero: the activity form offers programmes in the customer's own order, not alphabetical
PASS the dashboard computes its first-run checklist from the DATABASE
PASS ⚠️ the product says out loud that it is not a marketing tool
PASS ⚠️ and there is no bulk-send machinery anywhere to contradict it
== API-key scope ==
PASS api_keys gains a scope column defaulting to full — an upgrade never loosens
PASS requireKey reads the key scope and refuses a write on a read-only key
PASS mintKey persists the chosen scope, and anything but read is full access
PASS /mcp hands the key scope to the core, which refuses write tools per tool
PASS every MCP read tool declares readOnly, so a read-only key can see something
PASS the key form lets an administrator mint a read-only key
PASS the key-create route passes the chosen scope through to mintKey
PASS README documents the release and API.md gains the MCP section
PASS the deploy kit is filled and ships inside app/
PASS every shared core this product ships is byte-identical to the reference copy
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 balance email exists; the SMTP password is write-only ==
PASS balance email: both switches on, an address and SMTP — it may send (got '', want '')
PASS balance email: the master switch off refuses in words (got 'Mail is switched off in Settings.', want 'Mail is switched off in Settings.')
PASS balance email: the balance switch off refuses in words (got 'Balance emails are switched off in Settings.', want 'Balance emails are switched off in Settings.')
PASS balance email: a customer with no address is refused, not attempted (got 'This customer has no email address on file.', want 'This customer has no email address on file.')
PASS balance email: a demo install never sends (got 'Demo mode — no mail leaves this server.', want 'Demo mode — no mail leaves this server.')
PASS balance email: SMTP not configured is refused, not faked as sent (got 'SMTP is not configured.', want 'SMTP is not configured.')
PASS balance email: first name, the balance from the ledger, what it reaches now and what is next
PASS balance email: carries no other customer, no history lines, no link
PASS balance email: routed, permission-gated, CSRF-checked, refusal first, then the logged send
PASS balance email: the customer card offers the button only when both switches are on and there is an address
PASS balance email: a refused SMTP connection is recorded as failed in the outbox, and nothing throws
PASS mail: no dead operator-notice helper is left to promise a send (it had no caller)
PASS security: the settings page never prints the stored SMTP password, and a blank post keeps it
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 (32 checked)
PASS webhooks: import_writes_the_customer
PASS webhooks: import_opening_balance_is_an_adjustment
PASS webhooks: import_sends_customer_created
PASS webhooks: import_commit_goes_through_the_writer
PASS backup JSON: an administrator gets a backup, with every credential column planted (9)
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 281 TESTS PASSED (0 failed)