Visitora · Test run · run 2026-09-25
Visitora Test run, as shipped in the download
138 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 with php tests/run.php. Run it yourself after you download.
[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-09-25 00:19:00', want '2026-09-25 00:19:00') 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 normalise: 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 normalise: a leading + survives (got '+447700900111', want '+447700900111') PASS normalise: the "(0)" in +44 (0)7700 … means drop that zero, so one person stays one row (got '+447700900111', want '+447700900111') PASS normalise: a bare leading 0 is NOT dropped — without a country code it is part of the number (got '07700900111', want '07700900111') PASS normalise: 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-09-25 00:19:00', want '2026-09-25 00:19:00') 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 cancelled pass is cancelled 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 licence: every font ships the SIL OFL text for its own family beside it PASS licence: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause == 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 => 'visit.signed_in', 1 => 'visit.signed_out', ), want array ( 0 => 'visit.signed_in', 1 => 'visit.signed_out', )) PASS webhooks: the kiosk sign-in sends visit.signed_in; every sign-out door (kiosk, desk, REST, MCP, bulk) 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 (36 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 (12) 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 ———————————————————————————————————————————————————— ALL 138 TESTS PASSED (0 failed)