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

Visitora Test run, as shipped in the download

375 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.

[DB: SQLite]

== on site is derived, never stored ==
  PASS  happy: two sign-ins put two people on site (got 2, want 2)
  PASS  happy: onSite() returns the same people the count claims
  PASS  happy: the row carries the host and the sign-in time the list needs
  PASS  derived: there is NO is_onsite column to go stale
  PASS  derived: one definition of "on site", used by every reader

== signing out ==
  PASS  happy: signing out reports that THIS call did it
  PASS  happy: the person leaves the list (got 1, want 1)
  PASS  idempotent: a second sign-out does nothing and says so
  PASS  idempotent: the recorded departure time is not moved by the second call (got '2026-11-07 14:35:30', want '2026-11-07 14:35:30')
  PASS  idempotent: the count is unchanged by the second call (got 1, want 1)
  PASS  refusal: signing out a visit that does not exist is false, not a crash

== badges are reused, not exhausted ==
  PASS  happy: badges are handed out lowest-first (got 2, want 2)
  PASS  happy: a returned badge is the next one out again (got 1, want 1)
  PASS  happy: the badge is marked returned when its wearer signs out
  PASS  refusal: with every badge out, the next visitor gets 0 rather than a duplicate (got 0, want 0)
  PASS  refusal: that visitor is signed in with no badge, not turned away (got 0, want 0)
  PASS  refusal: nobody on site is wearing the same number as anybody else

== signing in refuses what it should ==
  PASS  refusal: somebody already inside cannot sign in twice (two rows, one body)
  PASS  happy: but somebody who has signed OUT can come back the same day
  PASS  refusal: a blank name is refused
  PASS  refusal: with require_host on, a visitor must choose somebody
  PASS  refusal: with rules required, they must be accepted
  PASS  refusal: an ARRAY where a name belongs is refused, never cast to the string "Array"
  PASS  refusal: a malformed email is refused

== one person, one row ==
  PASS  happy: a returning visitor reuses their row, matched on email (got 5, want 5)
  PASS  happy: the newest company is kept (got 'Northgate Security', want 'Northgate Security')
  PASS  happy: with no email, the phone matches — punctuation and spacing do not split a person
  PASS  refusal: a local number and an international one are NOT merged — the product cannot know they are the same person, and guessing would merge two strangers
  PASS  refusal: two people who gave neither email nor phone are NOT merged by name alone
  PASS  normalize: a number too short to dial is stored as nothing (got '', want '')
  PASS  dates: minusDays clamps a negative to zero — it can never reach FORWARDS, which is what makes it safe to hand a retention window straight to it (got '2026-03-10', want '2026-03-10')
  PASS  dates: and it goes backwards correctly across a month boundary (got '2026-02-25', want '2026-02-25')
  PASS  normalize: a leading + survives (got '+447700900111', want '+447700900111')
  PASS  normalize: the "(0)" in +44 (0)7700 … means drop that zero, so one person stays one row (got '+447700900111', want '+447700900111')
  PASS  normalize: a bare leading 0 is NOT dropped — without a country code it is part of the number (got '07700900111', want '07700900111')
  PASS  normalize: ordinary punctuation is stripped (got '+15550102030', want '+15550102030')

== accepted rules are self-contained ==
  PASS  happy: accepting the rules stores the exact text with the visit
  PASS  happy: and its fingerprint
  PASS  happy: the stored evidence verifies against itself
  PASS  immutable: changing the site rules cannot rewrite what somebody already accepted (got 'Wear your badge.', want 'Wear your badge.')
  PASS  immutable: and the old acceptance still verifies
  PASS  tamper-evident: an evidence row edited behind the app fails its fingerprint
  PASS  robust: a partial evidence row does not emit a PHP warning into the response
  PASS  robust: evidence with NO fingerprint is reported as not intact, never silently ok
  PASS  canon: CRLF and CR fold to LF so the same words hash the same everywhere (got 'a
b
c', want 'a
b
c')

== roll call (the hero interaction) ==
  PASS  happy: a fresh roll call has nobody accounted for (got 0, want 0)
  PASS  happy: everybody on site is missing until somebody says otherwise (got 4, want 4)
  PASS  happy: marking one person accounted for works
  PASS  happy: the tally moves by exactly one (got 1, want 1)
  PASS  happy: and the missing count follows it (got 3, want 3)
  PASS  idempotent: marking the same person again still reports success (two marshals, one list)
  PASS  idempotent: and does NOT move the time they were first accounted for (got '2026-11-07 14:35:31', want '2026-11-07 14:35:31')
  PASS  idempotent: the tally does not double-count (got 1, want 1)
  PASS  happy: a mark can be undone
  PASS  happy: undoing clears the time as well as the state (got NULL, want NULL)
  PASS  refusal: somebody who already left cannot be marked — they are not in the roll call
  PASS  refusal: a visit id that does not exist is refused, not created
  PASS  refusal: a state the product does not have is refused
  PASS  happy: resetting clears exactly the marks that were set (got 2, want 2)
  PASS  happy: after a reset nobody is accounted for (got 0, want 0)
  PASS  idempotent: resetting an already-clear roll call clears nothing (got 0, want 0)

== pre-registration ==
  PASS  happy: a pre-registration mints an unguessable token
  PASS  happy: it is valid on the day (got 'valid', want 'valid')
  PASS  happy: and on the following morning, so a delayed visitor is not turned away by arithmetic (got 'valid', want 'valid')
  PASS  refusal: two days later it has expired (got 'expired', want 'expired')
  PASS  happy: and it is valid before the day, for somebody who confirms early (got 'valid', want 'valid')
  PASS  happy: the real row minted above is valid today (got 'valid', want 'valid')
  PASS  refusal: a canceled pass is canceled whatever the date (got 'cancelled', want 'cancelled')
  PASS  refusal: a used pass is used (got 'used', want 'used')
  PASS  refusal: a date that is not a date is refused
  PASS  refusal: a host who does not exist is refused

== retention actually deletes ==
  PASS  happy: the old visit is in scope
  PASS  happy: purging removes it (got 1, want 1)
  PASS  happy: and everything attached to it
  PASS  happy: a visitor left with no visits at all goes too — no orphan identity survives
  PASS  refusal: nobody on site was touched, whatever the calendar says (got 4, want 4)
  PASS  idempotent: purging again deletes nothing (got 0, want 0)
  PASS  refusal: somebody still inside the building is never purged, however old the record
  PASS  retention: the date extract is driver-correct, not hard-coded to one engine

== durations read like a human wrote them ==
  PASS  duration: seconds (got '45s', want '45s')
  PASS  duration: minutes (got '10m', want '10m')
  PASS  duration: hours and minutes (got '2h 11m', want '2h 11m')
  PASS  duration: a whole hour drops the zero minutes (got '1h', want '1h')
  PASS  duration: over a day (got '1d 6h', want '1d 6h')
  PASS  duration: an open visit counts up to the moment asked about (got '2h 30m', want '2h 30m')
  PASS  duration: a clock that went backwards reads as zero, not as a negative (got '0s', want '0s')

== guards the runtime cannot prove ==
  PASS  guard: every App:: method the controllers call actually exists
  PASS  guard: every routed handler is defined
  PASS  guard: every POST route that mutates is CSRF-checked and permission-gated
  PASS  guard: the kiosk sign-in door is public BY DESIGN and says so where it is routed
  PASS  guard: every read-only MCP tool declares readOnly, so a read key can actually see it
  PASS  guard: the MCP endpoint applies the key scope, and /mcp answers GET with 405 before the key check
  PASS  guard: there is NO tool or endpoint that signs a visitor IN
  PASS  guard: neither serializer leaks a visitor phone number or an IP
  PASS  guard: a phone number and an IP are redacted out of any backup that leaves the box
  PASS  guard: outbound mail is gated on demo mode BEFORE any credential is read
  PASS  guard: mail needs BOTH the master switch and the event flag, and both default to 0
  PASS  guard: a mail failure can never break the request that triggered it
  PASS  guard: every mail attempt, refused or not, writes an outbox row
  PASS  guard: the purge cron refuses to answer over HTTP
  PASS  guard: the cron deletes through the SAME engine method the button uses — no second rule
  PASS  guard: overlapping purges are refused by an atomic mkdir, not a lock file
  PASS  guard: the purge audit records a COUNT and never a name
  PASS  guard: erasing a person is refused while they are inside the building
  PASS  guard: the evacuation screen, the CSV and the print view all read ONE query
  PASS  guard: the muster CSV carries the time it was generated
  PASS  guard: the roll call works without JavaScript — every row is a real form post
  PASS  guard: only /onsite may enter the offline cache, and signing out clears it
  PASS  guard: no standing statement is written as a non-error .main > .alert — the kit turns those into a five-second toast and the message evaporates
  PASS  guard: no page reaches an external host — fonts and scripts are bundled
  PASS  guard: the API key scope is enforced at the door, once, not per route
  PASS  guard: api_keys.scope is added by migrate() with DEFAULT full (an upgrade changes nothing)
  PASS  guard: the one write on the REST surface also consults the ROLE, not just the key
  PASS  guard: a viewer can see the building and change nothing; a member runs the desk
  PASS  guard: the shipped version string is one value, everywhere it is claimed
  PASS  guard: no OTHER product's name survives anywhere in the tree
  PASS  guard: the shared core modules were not forked for this product
  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

== F2: the kiosk tells the visitor the truth about their host; every door fires its webhook ==
  PASS  kiosk: on a default install the host email does not go (mail is off) — and says why
  PASS  kiosk: when the email did not go, the visitor reads "Reception will let Sam Host know", not "has been told"
  PASS  kiosk: when the email went, the visitor reads "Sam Host has been told you are here."
  PASS  kiosk: the sign-in hands the real send result to the thank-you screen, which reads it once
  PASS  webhooks: every event offered in Settings is sent by the app (got array (
  0 => 'delivery.received',
  1 => 'visit.signed_in',
  2 => 'visit.signed_out',
  3 => 'visit.watchlist',
), want array (
  0 => 'delivery.received',
  1 => 'visit.signed_in',
  2 => 'visit.signed_out',
  3 => 'visit.watchlist',
))
  PASS  webhooks: the kiosk sign-in sends visit.signed_in; every sign-out door (kiosk, desk, REST, MCP, bulk, badge QR) sends visit.signed_out
  PASS  webhooks: a visit.signed_in delivery is attempted and logged (the receiver here refuses the connection)
  PASS  visit: a sign-in that names no source is recorded as the kiosk (it stored an empty source, with a PHP warning) (got 'kiosk', want 'kiosk')
  PASS  forms: every POST form on a screen reaches a registered route (59 checked)
  PASS  demo ribbon: on the public pages it says what the page is, not "signed in as the admin"
  PASS  demo ribbon: below 600px it starts as the small pill unless the visitor opened it
  PASS  backups: every non-admin role gets 403 on every backup, restore and backup-token route
  PASS  backups: an administrator still gets the JSON backup (got 'GATE 200', want 'GATE 200')
  PASS  outbox: a pre-registration link never lands in the outbox log, whatever happens to the email
  PASS  backup JSON: an administrator gets a backup, with every credential column planted (19)
  PASS  backup JSON: the download carries none of them
  PASS  backup JSON: the scheduled backup carries none of them
  PASS  backup JSON: the download and the scheduled writer read the one redaction list
  PASS  security page: the backup card says every credential is redacted, not only the SMTP password

== 1.1 · staff codes and links ==
  PASS  code: fewer than 4 digits refused (got 'A kiosk code is 4 to 8 digits.', want 'A kiosk code is 4 to 8 digits.')
  PASS  code: letters refused (got 'A kiosk code is 4 to 8 digits.', want 'A kiosk code is 4 to 8 digits.')
  PASS  code: a run or a repeat refused (got array (
  0 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
  1 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
  2 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
), want array (
  0 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
  1 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
  2 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
))
  PASS  code: set (got '', want '')
  PASS  code: another member of staff cannot take the same code (got 'Another member of staff already uses that code.', want 'Another member of staff already uses that code.')
  PASS  code: stored as a keyed hash, never the digits
  PASS  code: looks the person up; a wrong or malformed code finds nobody
  PASS  code: a generated code is six digits and works
  PASS  link: 48 hex, stored as its SHA-256, finds its owner
  PASS  link: a new link replaces the old one
  PASS  a hidden (inactive) member of staff cannot tap in by code

== 1.1 · in and out (presence only) ==
  PASS  toggle: out -> in (got 'in', want 'in')
  PASS  signing in twice keeps one open row
  PASS  on site: the member of staff is on the list, with their name
  PASS  toggle: in -> out (got 'out', want 'out')
  PASS  signing out twice is one sign-out
  PASS  presence rows carry no hours or pay: the table is in/out, source and the roll-call mark only

== 1.1 · the roll call is everyone ==
  PASS  a contractor is recorded as one; anything else is a visitor (got array (
  0 => 'contractor',
  1 => 'visitor',
  2 => 'visitor',
  3 => 'visitor',
), want array (
  0 => 'contractor',
  1 => 'visitor',
  2 => 'visitor',
  3 => 'visitor',
))
  PASS  roll call: staff are counted with visitors and contractors
  PASS  roll call: marking a member of staff accounted for counts
  PASS  roll call: marking twice keeps the first time
  PASS  roll call: someone who has left cannot be marked
  PASS  roll call: a new drill clears staff marks too
  PASS  staff and visitors are "inside" by ONE rule (Visit::onSiteSql) — Presence has no second definition
  PASS  retention: finished staff presence rows older than the window go with the visits

== 1.1 · the host told in the team's chat ==
  PASS  chat: Slack, Teams and Google Chat get {"text": …} (got array (
  'text' => 'Ava Thompson (Northwind) has signed in at reception for Priya Raman — badge 4.',
), want array (
  'text' => 'Ava Thompson (Northwind) has signed in at reception for Priya Raman — badge 4.',
))
  PASS  chat: Discord gets {"content": …} (got array (
  0 => 'content',
), want array (
  0 => 'content',
))
  PASS  chat: a contractor is said so; sign-out is one line (got 'Contractor Ava Thompson (Northwind) has signed out.', want 'Contractor Ava Thompson (Northwind) has signed out.')
  PASS  chat: the signed JSON format is untouched (got NULL, want NULL)
  PASS  chat: the line never carries an email or a phone
  PASS  chat: every format offered is a real one
  PASS  backup: a staff code and a phone link never leave in the shareable backup
  PASS  texts: off by default, only through the owner's own gateway (no provider built in), and a host opts in alone

== 1.1 · the demo shows staff, contractors and visitors ==
  PASS  demo: staff on the roll call is on, four staff in, two contractors and two visitors inside (got array (
  0 => 1,
  1 => 4,
  2 => 2,
  3 => 2,
  4 => 8,
), want array (
  0 => 1,
  1 => 4,
  2 => 2,
  3 => 2,
  4 => 8,
))
  PASS  http: kiosk (staff off): no staff panel; the visitor/contractor choice is there
  PASS  http: owner signed in
  PASS  http: settings: the staff switch is there, and the text-message block starts off
  PASS  http: hosts page: presence controls for each person
  PASS  http: a kiosk code is generated and shown once
  PASS  http: a phone link is made and shown once
  PASS  http: kiosk (staff on): the staff panel with a numeric code box
  PASS  http: kiosk: the right code signs her in ("Good to see you, Priya")
  PASS  http: kiosk: wrong codes are refused, and after ten the screen is told to wait
  PASS  http: kiosk: once rate-limited, even the right code waits (the limit cannot be walked around)
  PASS  http: roll call: Staff, then Contractors, then Visitors
  PASS  http: roll call: one tally over all four
  PASS  http: roll call: a member of staff is marked accounted for, and the tally moves (1 of 4)
  PASS  http: CSV: a type column, staff first, contractor and visitor rows
  PASS  http: muster sheet: a Staff table and a visitors-and-contractors table
  PASS  http: phone link: shows "You are in" for Priya
  PASS  http: phone link: "I am leaving" signs her out
  PASS  http: phone link: an unknown link is a 404
  PASS  http: receptionist signed in
  PASS  http: a receptionist (member, host.edit) can manage presence on the hosts page
  PASS  http: API /api/on-site: staff listed and counted in on_site
  PASS  http: webhooks: the chat formats are offered
  PASS  http: Slack: the arrival arrives as one readable line, name, company, host and badge
  PASS  http: kiosk: with only a chat post, the visitor reads that it was posted to the team — not that the host was told
  PASS  http: security page: the shareable backup leaves out every webhook URL, and says to re-enter them
  PASS  http: the server log carries no PHP warning or fatal
  PASS  http: the walk made every check it planned (26)

== 1.1 pass B · wardens' phones ==
  PASS  wardens: the link is stored as a SHA-256, never itself
  PASS  wardens: a warden's tick goes through the same one rule as the staff's (Visit::markEvac / Presence::markEvac)
  PASS  demo: two fire wardens with links, after two resets (the links are never shown)
  PASS  demo: one watchlist entry (a contractor whose insurance lapsed), matched by nobody yet
  PASS  demo: the kiosk shows the badge with a Print button

== 1.1 pass B · the watchlist ==
  PASS  watchlist: names compare with case, accents, punctuation and word order ignored
  PASS  watchlist: matches by name (any order) or by email; not by one shared word
  PASS  watchlist: the chat line names the person and says "see Visitora" — never the note

== 1.1 pass B · deliveries ==
  PASS  deliveries: the chat line says what, for whom and from whom
  PASS  deliveries: the retention purge removes collected ones past the window and keeps any still waiting
  PASS  demo: deliveries on at the kiosk, one parcel waiting
  PASS  http (pass B): the owner and the viewer sign in
  PASS  http (pass B): roll call: the "Wardens' phones" section for whoever manages hosts
  PASS  http (pass B): a warden's link is made, shown once with a QR, then gone
  PASS  http (pass B): a viewer cannot make warden links
  PASS  http (pass B): the warden's phone: the roll call with no account — the list, tick forms to its own door, no staff tools or menu
  PASS  http (pass B): the warden's page: never indexed, never passes its address on, and its own home-screen manifest
  PASS  http (pass B): the manifest starts at the warden's own roll call
  PASS  http (pass B): two wardens tick people off from two phones at once; the tally is shared
  PASS  http (pass B): each tick is recorded under the warden's name
  PASS  http (pass B): the staff's roll call shows the wardens' ticks (2 accounted for, none missing)
  PASS  http (pass B): a revoked link, and one that never existed, answer "not found" — and change nothing
  PASS  http (pass B): offline: the service worker keeps each warden's roll call as it keeps /onsite
  PASS  http (pass B): settings: "The badge at the kiosk" (off, offer, print at once) with the any-printer note
  PASS  http (pass B): badge off (the default): the thank-you screen as before, no badge
  PASS  http (pass B): print at once: the kiosk shows the badge card (name, host, badge number) with a sign-out QR, and prints it on load
  PASS  http (pass B): the QR is stored only as a hash
  PASS  http (pass B): the badge prints once, and only on the kiosk that signed them in (not on a reload, not for anyone opening the address)
  PASS  http (pass B): scanned on the visitor's phone: "Leaving, Cleo?" with one button; never indexed, never passes its address on
  PASS  http (pass B): one tap signs her out: off the roll call, audited as the badge QR, and the page says so
  PASS  http (pass B): a barcode scanner at the kiosk's sign-out box reads the badge and signs him out
  PASS  http (pass B): reception's badge carries the QR too; printing it again replaces it (the old one answers "not found")
  PASS  http (pass B): an unknown code: "not signed in here", and nothing changes
  PASS  http (pass B): watchlist: the page for whoever manages hosts; a viewer cannot see it
  PASS  http (pass B): watchlist: added; the audit trail records the act, not the name or the note
  PASS  http (pass B): the kiosk says nothing different to her
  PASS  http (pass B): the visit is marked with the entry, and audited
  PASS  http (pass B): the desk email carries the reason (mail is off here, so it is recorded in the outbox as not sent, with the reason)
  PASS  http (pass B): reception sees it: a "watchlist" mark in the visit list and the reason on the visit
  PASS  http (pass B): someone who shares only a first name is not marked
  PASS  http (pass B): a subject-access export includes the watchlist entry about them
  PASS  http (pass B): the watchlist counts her matches
  PASS  http (pass B): removing the entry removes the mark from her visits
  PASS  http (pass B): deliveries off (the default): no delivery choice at the kiosk
  PASS  http (pass B): deliveries on: "Delivery for someone here?" and its form (who, what, company)
  PASS  http (pass B): the courier is thanked honestly: no email went, the team chat took it, so "posted to the team"
  PASS  http (pass B): the team chat line: "A delivery that needs a signature for Priya Raman is at reception (Swift Couriers)."
  PASS  http (pass B): for someone not on the list (typed name) it is kept; with nobody named it is refused
  PASS  http (pass B): a delivery is not a visit: nobody was added to the roll call
  PASS  http (pass B): reception: both waiting, each marked as told in team chat, and a count of 2 in the menu
  PASS  http (pass B): Priya's own phone page says a delivery is waiting for her
  PASS  http (pass B): a viewer sees the list but cannot mark it collected
  PASS  http (pass B): collected: it moves to the collected list with who took it; her phone page no longer mentions it; audited
  PASS  http (pass B): collecting twice changes nothing
  PASS  http (pass B): entrances: one per line, tidied, no repeats, at most 60 characters each; Settings shows each kiosk's address
  PASS  http (pass B): kiosk: opened once at its entrance's address, it names the door and remembers it in a cookie
  PASS  http (pass B): a sign-in at that kiosk records the entrance
  PASS  http (pass B): a name not on the list is not recorded, and a kiosk without an entrance shows none
  PASS  http (pass B): the roll call, both CSVs and the visit page show the door
  PASS  http (pass B): an entrance sent as a list is ignored, without a warning on the public page
  PASS  http (pass B): ?entrance= with nothing clears the kiosk's entrance
  PASS  http (pass B): settings: "Keep staff names off the kiosk" is there, off by default
  PASS  http (pass B): hidden: the kiosk carries no staff name anywhere, and asks visitors and couriers to type one
  PASS  http (pass B): a typed surname only one host has, or a full name in any case, finds the host
  PASS  http (pass B): a first name two hosts share finds nobody and is refused, naming no one; a posted host id is ignored
  PASS  http (pass B): the public thank-you screen names nobody
  PASS  http (pass B): host not required: a name that finds nobody is kept with the reason, for reception
  PASS  http (pass B): deliveries: a typed name finds the host; a posted host id alone is refused
  PASS  http (pass B): settings: a contractors' document, empty until written
  PASS  http (pass B): kiosk: the document is there for contractors, with its own box to accept
  PASS  http (pass B): a contractor who does not accept is not signed in; one who does has the text on the visit, intact; a visitor is not asked
  PASS  http (pass B): the server log carries no PHP warning or fatal
  PASS  http: the pass B walk made every check it planned (61)
  PASS  entrances (customer voice): one per line, trimmed, spaces folded, no repeats whatever the case, 60 characters, 12 at most
  PASS  entrances: the API and the webhooks carry the visit's entrance
  PASS  contractors' document (1.2): a contractor is refused until they accept it; accepted, it is kept on the visit with its fingerprint, beside the site rules; a visitor is not asked
  PASS  mcp roles: viewer_key_cannot_sign_out
  PASS  mcp roles: member_key_signs_out
  PASS  api roles: POST /api/visits/{id}/sign-out refuses every role without the permission (403) and admits the rest

== Text messages: the host told by text when a visitor arrives, through the owner's own SMS gateway ==
  PASS  texts: off by default — the switch, every host's opt-in and the visitor offer all start at 0; nothing reaches a gateway
  PASS  texts: a gateway address that is not https never counts as on
  PASS  gateway: JSON by default, fields to/from/text, the key as a Bearer token
  PASS  gateway: Twilio-style — To/Body form-encoded (no sender field when none is set), key as HTTP Basic
  PASS  consent: a host with a phone number on file but no opt-in gets no text
  PASS  consent: the host turns texts on with their own number (refused when it is not one); when is recorded
  PASS  arrival: the host's text names the business, the visitor and company, the badge, and a stop link — never the purpose
  PASS  once: the same arrival is never texted twice (claimed by its key before the gateway is called)
  PASS  kept: the stop token only as its SHA-256, the number only masked and hashed
  PASS  host page: with staff presence off but texts on, the phone link shows only the texts choice, with the number masked
  PASS  host page: turning on takes a typed number and an unticked box the host ticks
  PASS  host page: only the host's own link can turn texts on (the box is required); no administrator screen sets the opt-in
  PASS  stop: opening the link only asks; the page names the business and shows only part of the number
  PASS  stop: the button stops that number and turns the host's texts off; the next arrival is not texted
  PASS  stop: the host turning texts on again with their own number is a new request that lifts their stop
  PASS  consent: a host who turned texts off keeps the number on file but gets no text (the opt-in is in the query)
  PASS  failure: a refused or unreachable gateway is written on the text's row, and the sign-in carries on (never thrown)
  PASS  failure: Settings lists the latest texts with the gateway's answer; the key field never shows the key
  PASS  cap: past the daily limit nothing more goes today (a public kiosk cannot run up the bill)
  PASS  visitor: a confirmation only when the owner offers it AND the visitor ticked the box, once, naming the business, the host and a stop link
  PASS  visitor: a number that pressed stop gets no confirmation later, even when its owner ticks the box again on a new visit
  PASS  visitor: the page offers it unticked, and only when switched on
  PASS  retention: old text log rows go, and a visitor's number on an old pre-registration is cleared
  PASS  backup: the gateway key, a host's texting number and a text's stop token leave the shareable backup
  PASS  demo: the public demo never sends a text, and its text settings are read-only

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

== 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)
  PASS  updates: the updater table holds exactly one state row
  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 Visitora 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 Visitora 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 Visitora, 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 13 personal columns
  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 (28 tables dumped), outside the 4 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: another visitor's mail in the log is left as it was
  PASS  erase planting · sqlite: the erase page (/visitors/1) says what an erase keeps: "Kept: the purpose written on each visit, and an entry about …"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the purpose written on each visit, and an entry about …"
ALL 375 TESTS PASSED (0 failed)

← Back to Visitora · Manual · Quickstart · API