Home›Tokora›Test run
Tokora · Test run · run 2026-11-07

Tokora Test run, as shipped in the download

466 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. 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 neutralized (got '\'=SUM(A1)', want '\'=SUM(A1)')
  PASS  +1 neutralized (got '\'+1', want '\'+1')
  PASS  -cmd neutralized (got '\'-cmd|calc', want '\'-cmd|calc')
  PASS  @cmd neutralized (got '\'@cmd', want '\'@cmd')
  PASS  leading TAB neutralized (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 neutralized 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: numbering_carry_off_by_default
  PASS  v2: numbering_carries_on_when_switched_on
  PASS  v2: numbering_restarts_again_when_switched_off
  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: own_words_reach_the_owners_language_only
  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 color
  PASS  v2: palette tokens are WIRED, not just declared
  PASS  v2: sw cache is v4.1.3 (and NOT the old one, or a changed stylesheet serves stale) + API.md ships
  PASS  v2: /healthz, api_ping and openapi all report 4.1.3, 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 normalizes 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  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  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, labeled 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 (46 checked)
  PASS  F2 webhooks: the events offered in Settings (token.called, token.issued, token.served, token.transferred) 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, updater_state.license_api_key, backup_state.s3_secret)
  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()

== 3.2 · the estimated wait for THIS ticket ==
  PASS  estimate: no figure before three calls
  PASS  estimate: a 5-minute pace, 1 ahead -> 10 min
  PASS  estimate: first in line -> one gap (5 min)
  PASS  estimate: order does not matter, unreadable stamps are skipped
  PASS  estimate: the pace NOW (last ten gaps), not this morning's
  PASS  estimate: never below one minute
  PASS  estimate: a negative "ahead" counts as none
  PASS  nearly-up text: says roughly when, once the pace is known
  PASS  nearly-up text: the SMS sender passes the estimate

== 3.3 · the two texts in the owner's own words (research 28, Q078/Q062) ==
  PASS  own words: blank keeps Tokora's; one line; at most 320 characters; {label} is required
  PASS  own words: {label}, {counter} and {business} filled; {eta} stays empty until the pace is known, so no text says "0 min"
  PASS  own words: none written means Tokora's words, exactly as before

== 3.2 · the board that speaks ==
  PASS  spoken label: "A-014" is said "A 14"
  PASS  spoken label: a two-letter prefix is spelled out
  PASS  call-out: the default English sentence
  PASS  call-out: the owner's own words and placeholders
  PASS  call-out: no counter name -> "the counter"
  PASS  board.js: announces only calls it has not seen, seeded from the calls on screen at load
  PASS  board.js: speaks through the browser's own engine in the owner's language, after one tap
  PASS  board.js: the chime is synthesized (no sound file) and a stuck voice never blocks the queue
  PASS  board.js: still writes board data with textContent only (no markup from the feed)
  PASS  schema: the board's sound and QR settings exist and start off

== 3.2 pass B · service flows, staff numbers, messages ==
  PASS  label: a transferred ticket keeps the label it was issued with
  PASS  label: the kept label is said the same way ("R-003" -> "R 3")
  PASS  transfer: only a ticket being served can move on
  PASS  staff numbers: Ana called 2, served 2, handled in 5 min on average, her callers waited 10 min
  PASS  staff numbers: a no-show counts, has no handling time, and a never-called ticket is nobody's
  PASS  transfer: audited, and a token.transferred webhook (offered in Settings) fires
  PASS  messages: no email anywhere — the ticket page, and a text through the operator's own Twilio when switched on

== 3.2 pass B · visitor screens and texts in their language ==
  PASS  i18n: codes are normalized ("fr-FR" -> fr), anything unknown is English
  PASS  i18n: five packs, and every pack has every English line (no blank, no key name on a visitor screen)
  PASS  i18n: every translation keeps the English line's placeholders ({m}, {n}, {label}…) — none lost, none invented
  PASS  i18n: a sentence not in the packs comes back unchanged (the English is never lost)
  PASS  i18n: plurals — English 1/0/2
  PASS  i18n: plurals — French zero is singular
  PASS  i18n: plurals — Polish has three forms (1 osoba, 2-4 osoby, 5 osób, 12 osób, 22 osoby)
  PASS  languages offered: the default first, then the others once each, unknown codes dropped
  PASS  languages: a visitor gets what they asked for only if it is offered
  PASS  languages: a ticket keeps its visitor's choice, and one without a choice follows the default
  PASS  languages: the settings form keeps only known codes, never the default twice
  PASS  texts in the ticket's language: French, with the estimate and the default desk name
  PASS  texts in the ticket's language: German and Polish, and English is word for word as before
  PASS  texts: the SMS sender passes the ticket's language
  PASS  call-out: with no sentence of the owner's, the default is said in the voice's language
  PASS  call-out: the owner's own sentence always wins over the language
  PASS  schema: visitor languages start as English only, and a ticket starts with no choice
  PASS  i18n: still no email — languages reach visitors on their screens and by text only
  PASS  http: board (sound off): renders, keeps its meta refresh, shows no sound button and no QR
  PASS  http: board.json: says sound and QR are off
  PASS  http: board.json: each call carries a key and the sentence to say, in the default words
  PASS  http: ticket page: 1 ahead at a 5-minute pace -> "Estimated wait: about 10 min"
  PASS  http: ticket page: first in line -> about 5 min
  PASS  http: ticket page: carries the follow-on-your-phone QR (for the lobby kiosk screen)
  PASS  http: signed in as the owner
  PASS  http: settings: the board's sound and QR controls are there
  PASS  http: settings saved
  PASS  http: settings: "Carry numbers on from day to day" saves, shows ticked, and clears when unticked
  PASS  http: board (sound on): the refresh moves into <noscript>, and the tap-to-enable button shows
  PASS  http: board (QR on): the QR panel and the encoder are on the page, pointing at the kiosk
  PASS  http: board: the calls already showing are marked seen (not re-announced on load)
  PASS  http: board.json: the owner's own sentence in French, with the counter name
  PASS  http: settings: a language tag the speech engine cannot use falls back to en-GB
  PASS  http: recall: the call gets a new key, so the board says it again
  PASS  http: call-next: A-15 becomes the newest call-out
  PASS  http: kiosk: taking a ticket lands on its own page
  PASS  http: ticket page: not enough calls for service B yet -> no estimate shown
  PASS  http: console: the ticket being served offers a transfer and a message box
  PASS  http: message: the desk's words show on the visitor's ticket page
  PASS  http: message: the visitor's reply reaches the console (thread and a count in the live state)
  PASS  http: transfer: A-015 now waits in Consultation under the same number, at the front of that line
  PASS  http: transfer: the visitor's own link follows the ticket (same number, the new service, the thread kept)
  PASS  http: transfer: Room 3 calls it next, and the board says the kept number
  PASS  http: report: by counter and by staff, with the owner's calls credited to "Owner"
  PASS  http: report CSV: "Called by" and "Handling (s)" columns
  PASS  http: message: a finished ticket's thread is closed
  PASS  http: message: a visitor can send ten messages on one ticket, not an eleventh
  PASS  http: kiosk ?issued=<id>: no ticket's private follow link is shown by row id
  PASS  http: settings: "Visitor languages" offers a default and the others
  PASS  http: kiosk: one language offered -> no language links
  PASS  http: settings: English by default, French and Polish offered; an unknown code and the default are not stored twice
  PASS  http: kiosk: a link per offered language (English, Français, Polski — not Deutsch), in English by default
  PASS  http: kiosk ?lang=fr: the screen is in French and the form carries the choice
  PASS  http: kiosk ?lang=de (not offered): English
  PASS  http: kiosk in French: the ticket remembers French
  PASS  http: ticket page in French: status, people ahead, thread, footer
  PASS  http: ticket page in French: the service name stays as the owner typed it
  PASS  http: ticket page in French: a refusal reaches the visitor in French
  PASS  http: ticket page: the visitor switches to Polish (page and later texts)
  PASS  http: ticket page: a language the owner does not offer falls back to the default (stored as no choice)
  PASS  http: ticket page: changing the language needs the page's CSRF token
  PASS  http: ticket page: an unchosen ticket reads in English while English is the default
  PASS  http: API: a ticket issued with "lang": "pl" carries it, and its page is in Polish
  PASS  http: API: a language the owner does not offer is refused (422), naming the offered ones
  PASS  http: API: no "lang" -> the owner's default (lang: null)
  PASS  http: board with French as the default: headings and live-repaint words in French
  PASS  http: board.json: carries the language, and the default call-out is said in the VOICE's language (de-DE -> German)
  PASS  http: ticket page: an unchosen ticket now reads in French
  PASS  http: a ticket link that does not exist: the not-found page in the default language
  PASS  http: kiosk: French first now, English offered
  PASS  http: staff screens stay in English
  PASS  http: restore page: names webhook URLs and says to re-enter them; no SMTP password, no certificates
  PASS  http: settings: a box for your own words under each text, Tokora's words as the example
  PASS  http: own words: saved on one line; words without {label} are refused and the stored ones stay
  PASS  http: the server log carries no PHP warning or fatal
  PASS  http: the walk made every check it planned (57)
  PASS  numbering: the kiosk writer and a transfer take their number from the one rule (_tk_next_number, which reads "carry numbers on")
  PASS  api roles: POST /api/tokens refuses every role without the permission (403) and admits the rest
  PASS  mcp roles: viewer_key_cannot_issue
  PASS  mcp roles: operator_key_issues

————————————————————————————————————————————————————

== 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 Tokora 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 Tokora 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: under the standard keep count (14) every backup is 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 Tokora, 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 4 personal columns (+2 non-text)
  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 (19 tables dumped)
  PASS  erase planting · sqlite: no planted non-text value survives (2)
  PASS  erase planting · sqlite: the mobile number survives nowhere
  PASS  erase planting · sqlite: an operator cannot erase: HTTP 403, nothing changed
  PASS  erase planting · sqlite: an operator cannot open the Data retention page: HTTP 403
  PASS  erase planting · sqlite: an admin is refused while that number still has a ticket in the queue, and nothing changes
  PASS  erase planting · sqlite: an admin opens the page and it offers the erase by mobile number
ALL 466 TESTS PASSED (0 failed)

← Back to Tokora · Manual · Quickstart · API