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