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