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

Tokora Test run, as shipped in the download

279 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. Queue::normalizePrefix + label ==
  PASS  'p' -> 'P' (got 'P', want 'P')
  PASS  '  rx ' -> 'RX' (got 'RX', want 'RX')
  PASS  'A-1!' strips -> 'A1' (got 'A1', want 'A1')
  PASS  too long clipped to 4 (got 'ABCD', want 'ABCD')
  PASS  empty -> 'A' fallback (got 'A', want 'A')
  PASS  label('A',1) -> A-001 (got 'A-001', want 'A-001')
  PASS  label('P',14) -> P-014 (got 'P-014', want 'P-014')
  PASS  label('B',137) -> B-137 (got 'B-137', want 'B-137')
  PASS  label pads wider number (got 'C-1234', want 'C-1234')
  PASS  label normalizes prefix (got 'RX-007', want 'RX-007')

== 2. Queue::nextNumber — sequential per service, and the cron-free daily reset ==
  PASS  empty day -> 1 (got 1, want 1)
  PASS  [1,2,3] -> 4 (got 4, want 4)
  PASS  out of order [3,1,2] ->4 (got 4, want 4)
  PASS  gaps [1,5] -> 6 (max+1) (got 6, want 6)
  PASS  day 1 next = 5 (got 5, want 5)
  PASS  day 2 RESETS to 1 (got 1, want 1)
  PASS  day 2 after two issues (got 3, want 3)
  PASS  nextNumber idempotent (got true, want true)

== 3. Queue status state machine (allowedNext / canTransition) ==
  PASS  waiting -> [called] (got array (
  0 => 'called',
), want array (
  0 => 'called',
))
  PASS  called -> serve/no_show/wait (got array (
  0 => 'served',
  1 => 'no_show',
  2 => 'waiting',
), want array (
  0 => 'served',
  1 => 'no_show',
  2 => 'waiting',
))
  PASS  no_show -> called/waiting (got array (
  0 => 'called',
  1 => 'waiting',
), want array (
  0 => 'called',
  1 => 'waiting',
))
  PASS  served is terminal (got array (
), want array (
))
  PASS  waiting->called legal
  PASS  called->served legal
  PASS  called->no_show legal
  PASS  no_show->called (recall)
  PASS  waiting->served ILLEGAL
  PASS  served->anything ILLEGAL
  PASS  called->called not a transition

== 4. Queue::normalizeStatus + statusLabel ==
  PASS  junk -> waiting (got 'waiting', want 'waiting')
  PASS  'CALLED' -> called (got 'called', want 'called')
  PASS  no_show label (got 'No-show', want 'No-show')
  PASS  served label (got 'Served', want 'Served')

== 5. Queue::pickNext — next-token selection (FIFO by issued_at, tie by id) ==
  PASS  any-service picks oldest (id 2) (got 2, want 2)
  PASS  service 1 picks its oldest (id 9) (got 9, want 9)
  PASS  service 2 picks id 2 (got 2, want 2)
  PASS  service with no waiters -> null
  PASS  empty pool -> null
  PASS  tie on time -> lower id (4) (got 4, want 4)

== 6. Queue::aheadOf — how many are ahead of a given token ==
  PASS  one person ahead (got 1, want 1)
  PASS  first in line -> 0 ahead (got 0, want 0)

== 7. Queue::waitSeconds + avgWaitSeconds (PHP, from UTC timestamps) ==
  PASS  5 minutes = 300s (got 300, want 300)
  PASS  same instant = 0 (got 0, want 0)
  PASS  negative clamps 0 (got 0, want 0)
  PASS  missing called -> null
  PASS  missing issued -> null
  PASS  avg wait = 200s (got 200, want 200)
  PASS  no called rows -> null

== 8. Queue::fmtDuration ==
  PASS  null -> em dash (got '—', want '—')
  PASS  0 -> 0s (got '0s', want '0s')
  PASS  45 -> 45s (got '45s', want '45s')
  PASS  60 -> 1m (got '1m', want '1m')
  PASS  312 -> 5m 12s (got '5m 12s', want '5m 12s')
  PASS  3600 -> 1h (got '1h', want '1h')
  PASS  3720 -> 1h 2m (got '1h 2m', want '1h 2m')

== 9. Queue::dayStats — per-day statistics + idempotency ==
  PASS  issued = 6 (got 6, want 6)
  PASS  served = 2 (got 2, want 2)
  PASS  no_show = 1 (got 1, want 1)
  PASS  called = 1 (got 1, want 1)
  PASS  waiting = 2 (got 2, want 2)
  PASS  avg wait = 345s (called rows only) (got 345, want 345)
  PASS  empty day stats (got array (
  'issued' => 0,
  'waiting' => 0,
  'called' => 0,
  'served' => 0,
  'no_show' => 0,
  'avg_wait' => NULL,
), want array (
  'issued' => 0,
  'waiting' => 0,
  'called' => 0,
  'served' => 0,
  'no_show' => 0,
  'avg_wait' => NULL,
))
  PASS  dayStats idempotent (recompute identical)

== 10. Queue::csvCell — numeric-aware formula-injection guard ==
  PASS  =SUM neutralised (got '\'=SUM(A1)', want '\'=SUM(A1)')
  PASS  +1 neutralised (got '\'+1', want '\'+1')
  PASS  -cmd neutralised (got '\'-cmd|calc', want '\'-cmd|calc')
  PASS  @cmd neutralised (got '\'@cmd', want '\'@cmd')
  PASS  leading TAB neutralised (got '\'	x', want '\'	x')
  PASS  CR payload quoted+prefixed (got '"\'
X"', want '"\'
X"')
  PASS  -2 kept numeric (got '-2', want '-2')
  PASS  300 kept numeric (got '300', want '300')
  PASS  -45.00 kept numeric (got '-45.00', want '-45.00')
  PASS  plain text untouched (got 'Maya Rivera', want 'Maya Rivera')
  PASS  mid-string = untouched (got 'a=b', want 'a=b')
  PASS  comma/quote RFC-4180 quoted (got '"Smith, ""Jr"""', want '"Smith, ""Jr"""')

== 11. Queue::toCsv — header + rows, RFC-4180, CRLF ==
  PASS  header row (got 'Token,Service,Wait (s)', want 'Token,Service,Wait (s)')
  PASS  row count (header+2) (got 3, want 3)
  PASS  CRLF line endings
  PASS  comma field quoted
  PASS  formula cell neutralised in CSV
  PASS  wait seconds stay numeric

== 12. csrf_valid: empty-token bypass rejected (hash_equals('','') is TRUE) ==
  PASS  empty stored + empty given -> REJECTED
  PASS  empty stored + any given -> REJECTED
  PASS  matching token -> accepted
  PASS  mismatched token -> rejected

== 13. Queue::honeypotTripped (public kiosk bot guard) ==
  PASS  empty honeypot -> human
  PASS  missing field -> human
  PASS  filled honeypot -> bot

== 14. Pdf: produces a valid, non-trivial PDF document ==
  PASS  starts with %PDF header
  PASS  ends with %%EOF
  PASS  has xref table
  PASS  has a page object
  PASS  non-trivial length

== 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

== board credit (opt-out attribution) ==
  PASS  board view renders the credit ONLY behind the flag (opt-out is real)
  PASS  board payload wires the flag from settings (default ON)
  PASS  settings save persists the toggle (unchecked box = 0, not ignored)
  PASS  settings view carries the toggle
  PASS  schema has the column AND migrate() adds it to existing installs (duplicate-safe)

== Own It 2.0 ==
  PASS  v2: tables
  PASS  v2: totp_cols
  PASS  v2: no_smtp_columns
  PASS  v2: board_credit_untouched
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: rate
  PASS  v2: backup_tables
  PASS  v2: issue_is_sequential
  PASS  v2: issue_label_matches_core
  PASS  v2: duplicate_ticket_no_rejected_by_db
  PASS  v2: numbering_resets_next_day
  PASS  v2: fifo_picks_earliest
  PASS  v2: service_import_rejects_existing_prefix
  PASS  v2: preview_catches_intrafile_prefix_clash
  PASS  v2: commit_matches_intrafile_preview
  PASS  v2: counter_import_rejects_unknown_service
  PASS  v2: counter_import_allows_any_service
  PASS  v2: service_import_refuses_contentless_prefix
  PASS  v2: service_import_prefix_value_comes_from_core
  PASS  v2: alerts_phone_stored_normalised
  PASS  v2: alerts_off_by_default
  PASS  v2: alerts_unconfigured_refuses
  PASS  v2: alerts_second_send_refused
  PASS  v2: alerts_other_event_independent
  PASS  v2: alerts_never_block_a_call
  PASS  v2: alerts_field_hidden_without_an_account
  PASS  v2: retention_clears_the_number_too
  PASS  v2: board_feed_never_carries_the_visitor_note
  PASS  v2: webhook_issued_from_the_kiosk_writer
  PASS  v2: webhook_called_from_the_shared_claim
  PASS  v2: webhook_served_when_the_counter_moves_on
  PASS  v2: webhook_called_payload_names_the_counter
  PASS  v2: wal_sidecars_cleaned
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: migrate() runs on every boot (existing installs get the 2.0 tables)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: the issue API reuses _issue_token (daily reset + unique-number retry), not a copy
  PASS  v2: call-next reuses Queue::pickNext (FIFO), not a copy
  PASS  v2: /api/board serves the board page's own payload
  PASS  v2: /api/report serves the report page's own aggregation
  PASS  v2: the three queue webhooks fire from the shared writers, not only the API
  PASS  v2: NO mailer ships (Tokora has no email surface — deliberate, see api.php header)
  PASS  v2: no PHP-8 ternary key re-reads (PLAN item 10)
  PASS  v2: the CSV preview uses the same applier as the commit (dry-run cannot lie)
  PASS  v2: cli-server base guard (PLAN item 9)
  PASS  v2: core modules stay dependency-free (no product helpers inside them)
  PASS  v2: dark palette ships + theme.js + qr.js + layout wiring
  PASS  v2: the public display board stays dark in both themes (not swept)
  PASS  v2: no themed background paired with an un-overridden hardcoded colour
  PASS  v2: palette tokens are WIRED, not just declared
  PASS  v2: sw cache is v3.1.5 (and NOT the old one, or a changed stylesheet serves stale) + API.md ships
  PASS  v2: /healthz, api_ping and openapi all report 3.1.5, with no 3.0.0 (or any other literal version) left behind
  PASS  v2: the bump did not fork the shared core modules

== Own It 3.0 ==
  PASS  v3: every copied core is byte-identical to the shared core
  PASS  v3: the core Pdf is required and used
  PASS  v3: index.php calls Database::migrate() after the schemaExists guard
  PASS  v3: Database::migrate() ensures the settings singleton
  PASS  v3: the installer writes its settings even though the row already exists
  PASS  v3: v3_mcp_spec() provides every key Mcp::handle() reads
  PASS  v3: GET /mcp reaches the transport hint WITHOUT the key check in front of it
  PASS  v3: call-next claims its ticket, and there is only one claim
  PASS  v3: every surface calls through the one claim
  PASS  v3: token transitions carry their precondition
  PASS  v3: there is exactly one place a ticket is created
  PASS  v3: the follow page is keyed on the public token, not the row id
  PASS  v3: the branding logo serves publicly; anything else stays behind the login
  PASS  v3: every admin-surface handler is permission-gated, not merely login-gated
  PASS  v3: services and counters are not reachable through the operator's grants
  PASS  v3: the role ladder means what the Users screen says it means
  PASS  v3: every POST route that mutates is audited
  PASS  v3: the audit CSV reads the same rows the screen does, with the core's writer
  PASS  v3: the shareable backup redacts credentials, not just the SMTP password
  PASS  v3: password_hash and token_hash are in the redaction list
  PASS  v3: restore reports what it actually wrote
  PASS  v3: the restore page states the JSON-vs-.sqlite trade-off
  PASS  v3: retention clears the two fields that can identify somebody, and nothing else
  PASS  v3: still zero email — none was reintroduced
  PASS  v3: P6 is a clean opt-out — no Ical core, no calendar route
  PASS  v3: the accent AND the AA-safe ink are published to BOTH theme selectors
  PASS  v3: no rule hardcodes the light value of a token the dark theme redefines
  PASS  v3: filled controls read their ink from a token, and branding overrides that token
  PASS  v3: the uploaded logo is RENDERED, with a URL /media can serve
  PASS  v3: the report PDF is built from the day's stats, not re-counted
  PASS  v3: the report renders on the core Pdf and the legacy writer is gone
  PASS  v3: P6 is a clean opt-out — no Ical core shipped, no dead calendar route
  PASS  v3: every view gets the variables it reads from its controller
  PASS  v3: deploy kit ships
  PASS  v3: the deploy healthcheck is the frozen template's, not a hand-written substitute
  PASS  v3: no placeholder survived the fill
  PASS  v3: README documents the 3.0 release
  PASS  v3: API.md documents the MCP surface
  PASS  v3: no 2.0.0 version string survives outside the tests
  PASS  v3: every MCP tool carries a role guard (board + kiosk are the documented public pair)
  PASS  v3: call_next is gated on token.call in the screen, the API and the MCP tool
  PASS  v3: admin still administers, operator calls tickets, viewer writes nothing
  PASS  v3: /backup.sqlite is carved out of the blanket .sqlite deny (dev router AND .htaccess)
  PASS  v3: no POST route mutates the queue on the bare login guard
  PASS  v3: no REST handler writes on the bare key guard
  PASS  v3: the visitor alert opt-in is bound to its own token and nothing else
  PASS  nav: sidebar guard NOT decidable here (1 computed nav key(s)) — covered by the live audit

== UI kit: the console's live queue (read-only state endpoint + keyboard) ==
  PASS  console: state.json is routed and gated by the same permission as the console
  PASS  console: the state endpoint is READ-ONLY — no insert, no update, no claim
  PASS  console: it reports the waiting line in the ENGINE's order, scoped to this counter
  PASS  console: the keyboard drives the app's own forms, never a second write path
  PASS  console/engine: "next" is the oldest waiter, not the lowest id
  PASS  console/engine: a service-scoped counter never calls another service's token
  PASS  console/engine: a wait is rendered the same way the console shows it (got '7m 30s', want '7m 30s')

== 3.1 text alerts ==
  PASS  sms/happy: a configured, switched-on, un-sent ticket sends (got '', want '')
  PASS  sms/happy: the "you are next" body names the ticket and the business, and nothing else (got 'A-014 — you are next at Riverside Clinic. Please make your way back.', want 'A-014 — you are next at Riverside Clinic. Please make your way back.')
  PASS  sms/happy: the "called" body carries the counter (got 'A-014 — please go to Desk 2 at Riverside Clinic.', want 'A-014 — please go to Desk 2 at Riverside Clinic.')
  PASS  sms/happy: a note is never quotable into a message (the body takes no note at all)
  PASS  sms/refusal: demo mode refuses before any credential is read (got 'Demo mode — no message leaves this server.', want 'Demo mode — no message leaves this server.')
  PASS  sms/refusal: the event switch is off by default (got 'That message is switched off in Settings.', want 'That message is switched off in Settings.')
  PASS  sms/refusal: switched on but no Twilio account still sends nothing (got 'Twilio is not configured.', want 'Twilio is not configured.')
  PASS  sms/refusal: a ticket with no number is refused, not guessed at (got 'This ticket has no usable mobile number.', want 'This ticket has no usable mobile number.')
  PASS  sms/refusal: junk in the number box is refused rather than dialled (got 'This ticket has no usable mobile number.', want 'This ticket has no usable mobile number.')
  PASS  sms/refusal: the two events switch independently
  PASS  sms/idempotent: a second call on the same ticket sends nothing (got 'Already sent for this ticket.', want 'Already sent for this ticket.')
  PASS  sms/idempotent: the two events are stamped in separate columns, so one does not mask the other
  PASS  sms/idempotent: the stamp is only written when the send actually succeeded
  PASS  sms/permission: only the operator's own Twilio account is ever used — no Ownware endpoint
  PASS  sms/permission: the auth token is write-only in the settings form and never echoed
  PASS  sms/permission: saving the Twilio block needs settings.edit and refuses in the demo
  PASS  sms/permission: a blank token box keeps the stored one instead of wiping it
  PASS  sms: the send hangs off the ONE claim, so console, REST and MCP notify identically
  PASS  sms: a transport failure cannot break a call (the sender swallows its own errors)
  PASS  alerts/normalise: a number is kept in a dialable shape (got '+15551234567', want '+15551234567')
  PASS  alerts/normalise: too short is nothing at all (got '', want '')
  PASS  alerts/normalise: too long is nothing at all (got '', want '')
  PASS  alerts/permission: the opt-in is addressed by the unguessable token, never by row id
  PASS  alerts/refusal: a ticket that is no longer waiting cannot arm an alert
  PASS  alerts/refusal: the field is only offered where a message could actually be sent
  PASS  alerts/idempotent: clearing the box turns alerts off rather than leaving a stored number
  PASS  alerts: the number itself never reaches the audit trail
  PASS  alerts/permission: a number never leaves in a backup or an export
  PASS  alerts: the kiosk normalises in the ONE writer, so no junk number reaches a row
  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
  PASS  F2 PHP: the installer refuses PHP older than 8.1 with a plain message, before anything else runs
  PASS  F2 PHP: the requirements no longer say PHP 8.0 or "PHP 8+"
  PASS  F2 security: a signed-in non-admin (viewer, operator) gets 403 on every backup and restore route
  PASS  F2 security: the probe is real — an administrator is let through to the backup download
  PASS  F2 wait: no figure before 3 tickets have been called (one ticket is not a figure)
  PASS  F2 wait: the average of called tickets, rounded up to whole minutes (got array (
  'minutes' => 9,
  'samples' => 3,
), want array (
  'minutes' => 9,
  'samples' => 3,
))
  PASS  F2 wait: a ticket not yet called does not count as a sample
  PASS  F2 wait: the ticket page shows it only while waiting, labelled as today's typical wait
  PASS  F2 URL: with an absolute base_url the ticket link in a text carries the host once (it was doubled)
  PASS  F2 URL: with an absolute base_url the SSO redirect_uri carries the host once (it was doubled)
  PASS  F2 URL: without base_url both are still built from the request host
  PASS  F2 routes: every POST form in the views reaches a POST route (34 checked)
  PASS  F2 webhooks: the events offered in Settings (token.called, token.issued, token.served) are exactly the events the code sends
  PASS  F2b ribbon: the public page /kiosk says what it is ("this is the kiosk your visitors take a ticket at"), not "signed in as the admin"
  PASS  F2b ribbon: the public page /board says what it is ("this is the waiting-room screen that shows which ticket each counter is serving"), not "signed in as the admin"
  PASS  F2b ribbon: the public page /t/0f3a9c2b7d1e4f5a6b8c9d0e1f2a3b4c says what it is ("this is the page a visitor follows their ticket on"), not "signed in as the admin"
  PASS  F2b ribbon: an admin page (/services) keeps the admin wording
  PASS  F2b ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and that choice holds for the session
  PASS  F2b ribbon: injected once, just before </body>; output with no </body> (JSON, CSV, PDF) passes untouched
  PASS  F2b ribbon: boot() installs it only behind the .demo-mode marker
  PASS  F2b security: /backup/scheduled refuses a signed-in viewer, operator with no token or a wrong one (403)
  PASS  F2b backup: a value was planted and read back in every credential column (settings.oidc_client_secret, settings.backup_token_hash, settings.twilio_token, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, invites.token_hash)
  PASS  F2b backup: the JSON download carries none of them
  PASS  F2b backup: the scheduled backup carries none of them
  PASS  F2b backup: both backup doors use the one list, v3_redact()

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

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