Ownware
Home›Loyalora›Test run
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)

← Back to Loyalora · 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 →