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

Revup Test run, as shipped in the download

561 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. Template rendering ==
  PASS  all three placeholders (got 'Hi Ava, thanks from Purple Dental: https://x/r/t1', want 'Hi Ava, thanks from Purple Dental: https://x/r/t1')
  PASS  repeated placeholder (got 'Bo Bo', want 'Bo Bo')
  PASS  missing var -> empty (got 'Hi !', want 'Hi !')
  PASS  unknown braces untouched (got 'A {other}', want 'A {other}')
  PASS  no placeholders -> passthrough (got 'plain text', want 'plain text')
  PASS  HTML in value stays raw in message body (got '<b>x</b>', want '<b>x</b>')
  PASS  e() escapes for view context (got '&lt;b&gt;&quot;x&quot;&lt;/b&gt;', want '&lt;b&gt;&quot;x&quot;&lt;/b&gt;')
  PASS  firstName picks first word (got 'Ava', want 'Ava')
  PASS  firstName empty -> "there" (got 'there', want 'there')

== 2. Tokens ==
  PASS  token is 40 hex chars
  PASS  200 generated tokens are unique (got 200, want 200)

== 3. Open flow: every rating is shown the review link (threshold boundaries) ==
  PASS  open flow: the old gate decision (isPublic) no longer exists
  PASS  default 4: rating 3 -> also offered a private message (got true, want true)
  PASS  default 4: rating 4 -> no private offer (got false, want false)
  PASS  default 4: rating 1 -> also offered a private message (got true, want true)
  PASS  threshold 5: rating 4 -> also offered (got true, want true)
  PASS  threshold 3: rating 3 -> no private offer (got false, want false)
  PASS  threshold 1: private offer is off for every rating (got false, want false)
  PASS  threshold clamped: 0 behaves as 1 (off) (got false, want false)
  PASS  threshold clamped: 9 behaves as 5 (got true, want true)
  PASS  an invalid rating is never offered anything (got false, want false)
  PASS  state: unrated -> the stars (got 'rate', want 'rate')
  PASS  state: rated at/above threshold -> review links (got 'review', want 'review')
  PASS  state: rated below threshold, no message -> review links + private form (got 'feedback', want 'feedback')
  PASS  state: private message sent -> thanks + review links (got 'thanks', want 'thanks')
  PASS  open flow: no rating under any threshold lands in a state without the review link
  PASS  reviewLinks: primary offered in slot 1 with its label
  PASS  reviewLinks: a non-http(s) URL is never offered
  PASS  parseRating "5" -> 5 (got 5, want 5)
  PASS  parseRating "4" -> 4 (got 4, want 4)
  PASS  parseRating 0 -> null (got NULL, want NULL)
  PASS  parseRating 6 -> null (got NULL, want NULL)
  PASS  parseRating "abc" -> null (got NULL, want NULL)
  PASS  parseRating null -> null (got NULL, want NULL)

== 4. Clip/clamp on writes (MySQL strict-mode defense) ==
  PASS  contact name clipped to 190 (got 190, want 190)
  PASS  contact email clipped to 190 (got 190, want 190)
  PASS  contact phone clipped to 40 (got 40, want 40)
  PASS  contact tags clipped to 255 (got 255, want 255)
  PASS  campaign name clipped to 190 (got 190, want 190)
  PASS  campaign subject clipped to 190 (got 190, want 190)
  PASS  campaign template clipped to 10000 (got 10000, want 10000)
  PASS  bogus channel coerced to email (got 'email', want 'email')
  PASS  updateContact lowercases email (got 'ava@x.com', want 'ava@x.com')
  PASS  clamp_int caps range (got 2000000000, want 2000000000)
  PASS  clamp_int floor (got -2000000000, want -2000000000)
  PASS  clamp_int non-numeric -> 0 (got 0, want 0)

== 5. Request creation + single-use rating ==
  PASS  creates one request per valid unique contact (got 2, want 2)
  PASS  request tokens unique + hex
  PASS  new requests are pending (got 'pending', want 'pending')
  PASS  last_request_at stamped on contact
  PASS  re-create while open request exists -> skipped (got 0, want 0)
  PASS  canRate true before rating
  PASS  recordRating consumes token (got true, want true)
  PASS  recordRating second time rejected (single-use) (got false, want false)
  PASS  first rating persisted, not overwritten (got 5, want 5)
  PASS  rated_at stamped
  PASS  canRate false after rating
  PASS  rating alone does not mark the review page opened (got 0, want 0)
  PASS  markReviewOpened: an unrated request cannot be marked (got false, want false)
  PASS  markReviewOpened: first open is recorded (got true, want true)
  PASS  markReviewOpened: a second open is not a new event (webhook fires once) (got false, want false)
  PASS  markReviewOpened: redirected stamped (got 1, want 1)

== 6. Feedback storage + clipping ==
  PASS  feedback message clipped to 5000 (never 22001) (got 5000, want 5000)
  PASS  feedback rating stored (got 2, want 2)
  PASS  feedback starts unhandled (got 0, want 0)
  PASS  feedback rating clamped to 5 (got 5, want 5)

== 7. CSV import mapping ==
  PASS  mapped rows (got 3, want 3)
  PASS  blank row skipped (got 1, want 1)
  PASS  alias headers matched (Full Name) (got 'Ava T', want 'Ava T')
  PASS  email lowercased + kept (got 'ava@x.com', want 'ava@x.com')
  PASS  invalid email dropped to empty (got '', want '')
  PASS  oversize CSV name clipped to 190 (got 190, want 190)
  PASS  oversize CSV phone clipped to 40 (got 40, want 40)
  PASS  oversize CSV tags clipped to 255 (got 255, want 255)
  PASS  UTF-8 BOM on first header stripped (got 'Zed', want 'Zed')
  PASS  unmappable header -> nothing imported (got 0, want 0)
  PASS  row cap enforced (got 10, want 10)
  PASS  rows beyond cap counted as skipped (got 20, want 20)
  PASS  nameless row falls back to email (got 'only@x.com', want 'only@x.com')

== 8. CSV import into DB (dedupe by email) ==
  PASS  inserted (got 2, want 2)
  PASS  updated (deduped by email) (got 1, want 1)
  PASS  no duplicate rows created (got 1, want 1)
  PASS  update keeps old phone when new blank (got '+1777', want '+1777')
  PASS  formula content stored RAW in DB (guard is at export) (got '=cmd()', want '=cmd()')

== 9. CSV formula-injection guard (numeric-aware) ==
  PASS  =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  +cmd neutralized (got '\'+cmd', want '\'+cmd')
  PASS  +1 numeric -> KEPT (got '+1', want '+1')
  PASS  -text neutralized (got '\'-abc', want '\'-abc')
  PASS  negative number KEPT (got '-45.00', want '-45.00')
  PASS  leading TAB neutralized (got '\'	X', want '\'	X')
  PASS  leading CR neutralized + quoted
  PASS  plain value untouched (got 'Ava T', want 'Ava T')
  PASS  comma value quoted (got '"a,b"', want '"a,b"')
  PASS  quote value escaped (got '"say ""hi"""', want '"say ""hi"""')
  PASS  export header row (got 'name,phone', want 'name,phone')
  PASS  CRLF endings
  PASS  formula neutralized in real export
  PASS  negative number preserved in export
  PASS  empty export -> empty string (got '', want '')

== 10. Mailer message building (no socket) ==
  PASS  From header present
  PASS  To header present
  PASS  Subject header present
  PASS  MIME text/plain utf-8
  PASS  CRLF header/body separator
  PASS  dot-stuffing applied
  PASS  CRLF stripped from To (header injection)
  PASS  CRLF stripped from Subject (header injection)
  PASS  encodeHeader ASCII passthrough (got 'Hello', want 'Hello')
  PASS  encodeHeader UTF-8 -> RFC 2047 (got '=?UTF-8?B?Q2Fmw6k=?=', want '=?UTF-8?B?Q2Fmw6k=?=')
  PASS  dotStuff normalizes lone LF to CRLF (got 'a
b', want 'a
b')
  PASS  dotStuff escapes leading dot at start (got '..x', want '..x')
  PASS  send refuses when unconfigured (no socket)
  PASS  send refuses invalid recipient before any socket
  PASS  Mailer::configured false on blank
  PASS  Mailer::configured true on host+from

== 11. Sms request building (no network) ==
  PASS  URL hard-coded to api.twilio.com
  PASS  SID rawurlencoded in path (no traversal)
  PASS  URL ends with /Messages.json
  PASS  From urlencoded
  PASS  To urlencoded
  PASS  Body urlencoded (& escaped)
  PASS  auth is sid:token (got 'AC123/../evil:tok', want 'AC123/../evil:tok')
  PASS  SMS body clipped to 1600 (got 1600, want 1600)
  PASS  Sms::configured false on blanks
  PASS  Sms::send refuses when unconfigured (no network)
  PASS  Sms::send refuses invalid phone before network

== 12. Sender modes: demo-block, manual, missing address ==
  PASS  demo mode blocks send, marks sent (got 'sent', want 'sent')
  PASS  demo mode via = demo (got 'demo', want 'demo')
  PASS  demo outcome persisted on row (got 'demo', want 'demo')
  PASS  demo sent_at stamped
  PASS  manual mode keeps pending (got 'pending', want 'pending')
  PASS  manual mode via = manual (got 'manual', want 'manual')
  PASS  manual persisted as pending (got 'pending', want 'pending')
  PASS  missing address -> failed (got 'failed', want 'failed')
  PASS  missing address error message set
  PASS  sms unconfigured -> pending/manual (got 'pending/manual', want 'pending/manual')
  PASS  gateLink contains /r/<token>

== 13. Gate rate limit (per-IP) ==
  PASS  61st hit from same IP blocked
  PASS  different IP unaffected

== 14. Auth ==
  PASS  password hash verifies
  PASS  stored hash verifies
  PASS  wrong password rejected

== 15. Stats ==
  PASS  stats sent >= 1
  PASS  stats rated counts ratings
  PASS  stats avg is float or null
  PASS  below + at_or_above == rated
  PASS  stats: opened_review counts raters who opened the review page
  PASS  stats: the gate-era keys are gone (no "kept private" figure)

== 16. Security hardening regressions ==
  PASS  csrf_check rejects empty stored token (hash_equals empty-token bypass)
  PASS  installer rejects empty install token (_it bypass)
  PASS  settings_save blocks writes in public demo mode
  PASS  Sender demo-block sits BEFORE any credential/config read
  PASS  Twilio endpoint hard-coded https://api.twilio.com
  PASS  Twilio client never follows redirects
  PASS  no error_log of credentials anywhere in senders
  PASS  settings view never echoes smtp_pass value
  PASS  settings view never echoes twilio_token value
  PASS  settings view never echoes twilio_sid value
  PASS  demo session cookie is None + Secure + Partitioned
  PASS  buyer session cookie is HttpOnly + Lax + Secure-on-HTTPS
  PASS  gate view shows first name only (no email/phone echoed)
  PASS  gate feedback form carries the honeypot field
  PASS  gate handlers enforce the per-IP rate limit
  PASS  gate token lookup is parameterized
  PASS  public review links carry rel=noopener
  PASS  dev router denies config.php / data / .sqlite
  PASS  .htaccess denies config.php / sqlite / demo marker
  PASS  portable UTC timestamps (no SQLite datetime() in controllers)
  PASS  no stack traces: front controller catches boot errors

== 3.1.4 open flow (rendered rating page) ==
  PASS  open flow: the 'review' page links to the public review page
  PASS  open flow: the 'feedback' page links to the public review page
  PASS  open flow: the 'thanks' page links to the public review page
  PASS  open flow: a low rating sees the review link BEFORE the private form, never instead of it
  PASS  open flow: the private form is only on the below-threshold page
  PASS  open flow: the page never tells a customer their message replaces a review
  PASS  open flow: gate_rate never sends a rating straight to an external review URL
  PASS  open flow: the review click-through route is registered and records the open
  PASS  open flow: settings can switch the private offer off (threshold 1 is selectable)
  PASS  open flow: the dashboard no longer sells a count that "never reached a public profile"
  PASS  tile trend: six months, oldest first (got array (
  0 => 'Apr',
  1 => 'May',
  2 => 'Jun',
  3 => 'Jul',
  4 => 'Aug',
  5 => 'Sep',
), want array (
  0 => 'Apr',
  1 => 'May',
  2 => 'Jun',
  3 => 'Jul',
  4 => 'Aug',
  5 => 'Sep',
))
  PASS  tile trend: counts SENT requests by send month (pending/failed and creation month ignored) (got array (
  0 => 0,
  1 => 0,
  2 => 0,
  3 => 0,
  4 => 1,
  5 => 1,
), want array (
  0 => 0,
  1 => 0,
  2 => 0,
  3 => 0,
  4 => 1,
  5 => 1,
))
  PASS  tile trend: the quarter total can never exceed the "Requests sent" total
  PASS  tile trend: the dashboard reads Revup::sentPerMonth, not created_at

== 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

-- Own It 2.0 --
  PASS  v2: tables
  PASS  v2: totp_cols
  PASS  v2: notify_cols
  PASS  v2: smtp_preexisting
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: csv_refuses_unreachable
  PASS  v2: backup_tables
  PASS  v2: gate_reuse
  PASS  v2: feedback_no_token
  PASS  v2: request_sent_fires_from_the_sender
  PASS  v2: request_sent_not_refired_by_a_followup
  PASS  v2: runner_releases_what_is_due
  PASS  v2: runner_second_pass_same_day_is_a_noop
  PASS  v2: runner_followup_waits_for_the_gap
  PASS  v2: runner_followup_skips_who_answered
  PASS  v2: runner_followup_leaves_the_first_send_alone
  PASS  v2: runner_followup_is_sent_once
  PASS  v2: runner_stamps_every_pass
  PASS  v2: runner_respects_the_off_switch
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: negative feedback notifies the operator + fires a webhook, never fatally
  PASS  v2: reuses revup OWN Mailer — MailerLite is deliberately NOT shipped
  PASS  v2: API reuses the UI core (Revup::createRequests + _send_requests)
  PASS  C2: requireKey filters users.active (deactivating a user revokes their key)
  PASS  v2: no ternary key re-reads in ANY controller (PHP 8 warning hygiene, PLAN item 10)
  PASS  v2: dark palette + zero hardcoded white backgrounds
  PASS  v2: theme.js + qr.js ship and layout loads theme.js + a toggle
  PASS  v3: API.md ships + sw cache is v4.1.3 (and not the old name, or a changed stylesheet serves stale)
  PASS  v2: cli-server base guard present
  PASS  v3: index.php calls Database::migrate() at boot (1.x/2.0 installs gain their columns)
  PASS  v3: an installer-shaped install has every column the code writes
  PASS  v3: no literal route is shadowed by an earlier {id} route
  PASS  v3: the CSV import form posts to a route that reaches the importer
  PASS  v3: version is 4.1.3 everywhere (healthz, api_ping, openapi), with nothing older left behind
  PASS  v3: the bump did not fork the shared core modules
  PASS  v3: all 9 shared cores are byte-identical to the reference copy
  PASS  v3: the deploy kit ships both files, fully filled
  PASS  v3: the deploy kit names THIS product and carries the fixed healthcheck
  PASS  v3: the brand accent is applied in :root AND html[data-theme=dark]
  PASS  v3: an uploaded logo and the brand name reach the app chrome, not just the settings page
  PASS  v3: GET /mcp is routed to the unauthenticated probe (a 401 hides that we speak MCP)
  PASS  v3: the GET probe runs no key check and exposes no tools
  PASS  v3: GET reaches the core and gets 405 + Allow: POST
  PASS  v3: MCP creates requests through Revup::createRequests(), never its own INSERT
  PASS  v3: MCP cannot change the gate threshold or the review URL
  PASS  v3: exactly ONE send-window planner, used by the preview and the release
  PASS  v3: every advertised reply merge field actually resolves
  PASS  v3: an unknown placeholder is left alone rather than blanked
  PASS  v3: reply merge fields are data keys only
  PASS  v3: both backup doors use one redaction list
  PASS  v3: the redaction list names every credential column revup stores
  PASS  v3: no call to a function this product does not define
  PASS  v3: the 2.0 settings and API-key handlers are permission-gated too
  PASS  v3: every v3 write handler is permission-gated
  PASS  C2: no sensitive handler is login-only — all are role-gated
  PASS  C2: both backup doors call the ONE shared redaction list
  PASS  C2: Api::requireKey refuses a deactivated user (u.active)
  PASS  C2: every App:: method the controllers call actually exists
  PASS  C2: Api::base() does not double an already-absolute base
  PASS  nav: every sidebar link is highlighted by the page it opens
  PASS  nav: no page highlights a sidebar item that does not exist
  PASS  ip: a direct visitor's forged headers are ignored
  PASS  ip: behind a trusted proxy the forwarded visitor is used
  PASS  ip: CF-Connecting-IP wins over the X-Forwarded-For chain
  PASS  ip: the chain is read right-to-left, past trusted hops
  PASS  ip: a Cloudflare edge is not a trusted proxy by default
  PASS  ip: CIDR maths (v4 boundaries + v6, no cross-family match)

== UI kit: the send queue runs in slices with visible progress ==
  PASS  send-batch: routed, permission-gated and CSRF-checked like the single send
  PASS  send-batch: dispatches through the SHARED sender, never its own copy
  PASS  send-batch: a slice is bounded — a long queue can never become one long request
  PASS  send-batch: the progress list carries a FIRST NAME only, like the public gate
  PASS  send-batch: every run is audited with what it sent and what is left
  PASS  send-batch: the runner stops itself when nothing is moving (no infinite loop)
  PASS  send-batch/engine: the private-offer rule the queue reports is Revup's own
  PASS  send-batch/engine: a bad rating parses, it is the threshold that gates it (got 3, want 3)

== 3.1 schedule + follow-up ==
  PASS  schedule/happy: a running window with work releases once the hour has come (got '', want '')
  PASS  schedule/happy: it releases exactly AT the chosen hour, not only after it (got '', want '')
  PASS  schedule/happy: a window that missed its hour still goes out on the next pass (got '', want '')
  PASS  schedule/happy: the hour is clamped into a real day, whatever is stored
  PASS  schedule/refusal: automatic release is off unless someone turned it on (got 'Automatic release is off for this window.', want 'Automatic release is off for this window.')
  PASS  schedule/refusal: a finished window is never revived by the runner (got 'This window is not running.', want 'This window is not running.')
  PASS  schedule/refusal: before the hour, nothing goes out (got 'Not yet — this window releases at 09:00 UTC.', want 'Not yet — this window releases at 09:00 UTC.')
  PASS  schedule/refusal: nothing due means nothing sent, however loudly the cron fires (got 'Nothing is due yet.', want 'Nothing is due yet.')
  PASS  schedule/idempotent: a second pass on the same day releases nothing (got 'Already released today.', want 'Already released today.')
  PASS  schedule/idempotent: yesterday's stamp does not block today
  PASS  schedule/idempotent: the day is CLAIMED before anything is sent, so a crash cannot double-send
  PASS  followup/happy: silence for the full gap earns exactly one reminder (got '', want '')
  PASS  followup/happy: the gap counts from when the first message actually went out (got 'Too soon — 7 day(s) have not passed since it was sent.', want 'Too soon — 7 day(s) have not passed since it was sent.')
  PASS  followup/happy: the reminder reuses the SAME link — it is the same single-use ask
  PASS  followup/happy: it says once that it is a reminder, and adds nothing else
  PASS  followup/refusal: a customer who rated is never nudged again (got 'This customer already rated.', want 'This customer already rated.')
  PASS  followup/refusal: a low rating counts as an answer too — feedback is not silence (got 'This customer already rated.', want 'This customer already rated.')
  PASS  followup/refusal: someone who went to the review page has already been asked enough (got 'This customer already went to the review page.', want 'This customer already went to the review page.')
  PASS  followup/refusal: a request whose first message never left is not followed up (got 'The first message never went out.', want 'The first message never went out.')
  PASS  followup/refusal: no gap configured means no reminder, ever (got 'No follow-up is configured for this window.', want 'No follow-up is configured for this window.')
  PASS  followup/refusal: a nonsense gap is read as OFF rather than as an instruction
  PASS  followup/idempotent: exactly one reminder, whatever else runs (got 'The follow-up already went out.', want 'The follow-up already went out.')
  PASS  followup/idempotent: the stamp is written ONLY on a successful send
  PASS  followup/idempotent: a reminder never rewrites the first message's own history
  PASS  followup/idempotent: the runner only ever looks at requests with no stamp yet
  PASS  schedule/permission: both new POSTs need batch.run and a CSRF token
  PASS  schedule/permission: the cron door refuses to answer over HTTP
  PASS  schedule/permission: the public demo never runs the schedule
  PASS  schedule/permission: overlapping runs are refused by an atomic lock, not a lock file
  PASS  schedule: the runner reuses the product's OWN sender — no second send path
  PASS  schedule: a stopped runner is VISIBLE — every pass is stamped and the page reads it
  PASS  followup/refusal: manual mode is not a failure — an install with no channel is not nagged
  PASS  followup/refusal: a follow-up with no channel is NOT stamped, so it goes out once SMTP is set up
  PASS  schedule: a manual run and the cron run take the SAME path
  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, member) 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 invite: with SMTP set up, the invitation is delivered over a real SMTP conversation, carrying the link
  PASS  F2 invite: with no SMTP it says so and sends nothing (the link is still shown once)
  PASS  F2 invite: a refusing mail server is reported, never thrown
  PASS  F2 invite: the invite handler emails it and still shows the link once
  PASS  F2 routes: every POST form in the views reaches a POST route (51 checked)
  PASS  F2 webhooks: the events offered in Settings (feedback.received, rating.public, request.sent, testimonial.approved) are exactly the events the code sends
  PASS  F2b ribbon: the public page /r/Ab3dEf6hIj9kLm2nOp5q says what it is ("this is the page your customers rate you on"), not "signed in as the admin"
  PASS  F2b ribbon: an admin page (/requests) 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, member with no token or a wrong one (404)
  PASS  F2b backup: a value was planted and read back in every credential column (settings.smtp_pass, settings.twilio_token, settings.backup_token_hash, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, updater_state.license_api_key, backup_state.s3_secret, invites.token_hash, triggers.url_key, triggers.secret, triggers.remote_api_key)
  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, revup_backup_redact()

== 3.2 · team performance ==
  PASS  team: a request is credited to the customer's "served by" when none is named (got 'Dr Patel', want 'Dr Patel')
  PASS  team: a team member named on the ask wins over "served by", cleaned of extra spaces (got 'Sam Reid', want 'Sam Reid')
  PASS  team: no one named and no "served by" -> not recorded (got '', want '')
  PASS  team: one row per team member, plus the unattributed row
  PASS  team: Dr Patel asked 1, rated 1, average 5 (got array (
  0 => 1,
  1 => 1,
  2 => 5.0,
  3 => 1,
), want array (
  0 => 1,
  1 => 1,
  2 => 5.0,
  3 => 1,
))
  PASS  team: the unattributed row counts the unanswered ask (0% response) (got array (
  0 => 1,
  1 => 0,
), want array (
  0 => 1,
  1 => 0,
))
  PASS  team: best average first, "Not recorded" always last (got array (
  0 => 'Dr Patel',
  1 => 'Sam Reid',
  2 => '',
), want array (
  0 => 'Dr Patel',
  1 => 'Sam Reid',
  2 => '',
))
  PASS  team: a CSV "Served by" column is read on import (got 'Dr Okoye', want 'Dr Okoye')
  PASS  team: re-importing a known customer updates who served them (got 'Dr Okoye', want 'Dr Okoye')
  PASS  team: the CSV importer spec and the REST contact create both accept served_by

== 3.2 · testimonials and the website widget ==
  PASS  displayName: first name + last initial (got 'Ava T.', want 'Ava T.')
  PASS  displayName: one word stays as it is (got 'Cher', want 'Cher')
  PASS  displayName: empty -> "A customer" (got 'A customer', want 'A customer')
  PASS  quote offer: 5 stars on the plain thank-you state
  PASS  quote offer: never below 4 stars
  PASS  quote offer: never beside the private-message form
  PASS  quote offer: never before a rating
  PASS  quote offer: off when the owner switches it off
  PASS  quote: refused without the consent box (got 'Tick the box to say we may show your comment.', want 'Tick the box to say we may show your comment.')
  PASS  quote: refused when empty (got 'Write a sentence or two first.', want 'Write a sentence or two first.')
  PASS  quote: saved with consent (got '', want '')
  PASS  quote: waits for approval, clipped to 1,000 characters, named "Tess S." (got array (
  0 => 'pending',
  1 => 1000,
  2 => 'Tess S.',
  3 => true,
), want array (
  0 => 'pending',
  1 => 1000,
  2 => 'Tess S.',
  3 => true,
))
  PASS  quote: one per request (got 'Thank you — we already have your comment.', want 'Thank you — we already have your comment.')
  PASS  widget: nothing waiting for approval is shown (got array (
), want array (
))
  PASS  widget: an approved quote is shown with its name and stars (got array (
  0 => 1,
  1 => 'Tess S.',
  2 => 5,
), want array (
  0 => 1,
  1 => 'Tess S.',
  2 => 5,
))
  PASS  widget: the average is over EVERY rating received, not the quoted ones (got array (
  0 => 4.0,
  1 => 2,
), want array (
  0 => 4.0,
  1 => 2,
))
  PASS  widget: the average is not simply the quoted 5 stars (a low rating exists in the suite)
  PASS  widget: a hidden quote disappears (got array (
), want array (
))
  PASS  widget: an unknown status is refused (got NULL, want NULL)
  PASS  widget: the average is left out when switched off (got NULL, want NULL)
  PASS  widget: the snippet is two lines, served from this install
  PASS  share: X gets the quote, the name, the stars and the website
  PASS  share: Facebook and LinkedIn take only the link
  PASS  share: with no website or review URL there is no Facebook link (it needs one)
  PASS  share: testimonial.approved is a webhook event a receiver can choose, and approval fires it
  PASS  share: the event payload carries the share links (the review URL stands in for a missing website) (got array (
  0 => 'text',
  1 => 'url',
  2 => 'x',
  3 => 'facebook',
  4 => 'linkedin',
), want array (
  0 => 'text',
  1 => 'url',
  2 => 'x',
  3 => 'facebook',
  4 => 'linkedin',
))

== 3.2 · the referral ask ==
  PASS  referral: offered after 5 stars when on
  PASS  referral: not after 4 stars at the default bar
  PASS  referral: after 4 stars when the owner lowers the bar
  PASS  referral: off by default
  PASS  referral: never without a landing page
  PASS  referral: a 10-character code, stable, and not the rating token
  PASS  referral: codes differ between customers
  PASS  referral: ?ref= is added to a plain URL (got 'https://x.example/book?ref=abc123', want 'https://x.example/book?ref=abc123')
  PASS  referral: an existing query and fragment are kept (got 'https://x.example/book?utm=a&ref=abc123#top', want 'https://x.example/book?utm=a&ref=abc123#top')
  PASS  referral: the default message names the business and carries the link (got 'I had a good experience with Purple — have a look: https://r/ref/x', want 'I had a good experience with Purple — have a look: https://r/ref/x')
  PASS  referral: a custom message without {link} still carries the link (got 'Try P! L', want 'Try P! L')
  PASS  referral: a visit is counted against the referring customer
  PASS  referral: an unknown or malformed code counts nothing
  PASS  referral: the leaderboard puts the customer with visits first

== 3.2 · NPS ==
  PASS  nps: 0 and 10 accepted (got array (
  0 => 0,
  1 => 10,
), want array (
  0 => 0,
  1 => 10,
))
  PASS  nps: 11, -1, 7.5 and text refused (got array (
  0 => NULL,
  1 => NULL,
  2 => NULL,
  3 => NULL,
), want array (
  0 => NULL,
  1 => NULL,
  2 => NULL,
  3 => NULL,
))
  PASS  nps: refused before a rating
  PASS  nps: recorded once after a rating
  PASS  nps: 1 promoter, 1 detractor -> score 0 (got array (
  0 => 2,
  1 => 1,
  2 => 1,
  3 => 0,
), want array (
  0 => 2,
  1 => 1,
  2 => 1,
  3 => 0,
))
  PASS  nps: 2 promoters, 1 passive, 1 detractor of 4 -> 25 (got 25, want 25)
  PASS  nps: asked after the rating, once, only when switched on
  PASS  nps: the REST request shape carries staff and nps (got array (
  0 => 'Dr Patel',
  1 => 10,
), want array (
  0 => 'Dr Patel',
  1 => 10,
))

== 3.2 · reply drafts (your own AI assistant drafts; a person sends) ==
  PASS  reply: a draft is saved, trimmed, with who drafted it (got array (
  0 => 'Sorry about the wait.',
  1 => 'agent:bot@example.com',
), want array (
  0 => 'Sorry about the wait.',
  1 => 'agent:bot@example.com',
))
  PASS  reply: no such message -> null (got NULL, want NULL)
  PASS  reply: sending refuses when email is not set up (got 'Email is not set up — add your SMTP details in Settings, or copy the reply.', want 'Email is not set up — add your SMTP details in Settings, or copy the reply.')
  PASS  reply: the public demo sends nothing (got 'The public demo sends no email. On your own install this goes through your SMTP.', want 'The public demo sends no email. On your own install this goes through your SMTP.')
  PASS  reply: a customer with no email is refused with the reason
  PASS  reply: sent over a real SMTP conversation to the customer, with the reply text
  PASS  reply: a sent reply is recorded and the message marked handled (got array (
  0 => true,
  1 => 1,
  2 => 'owner@purple.example',
), want array (
  0 => true,
  1 => 1,
  2 => 'owner@purple.example',
))
  PASS  reply: a sent reply can be neither redrafted nor sent twice
  PASS  mcp: list_feedback is read-only and draft_reply writes
  PASS  mcp: list_feedback returns the messages with the first name only (no email)
  PASS  mcp: draft_reply saves a draft marked as the agent's and sends nothing (got array (
  0 => 'draft',
  1 => 'agent:agent-owner@example.com',
  2 => NULL,
), want array (
  0 => 'draft',
  1 => 'agent:agent-owner@example.com',
  2 => NULL,
))
  PASS  mcp: a viewer's key cannot save a draft
  PASS  mcp: a sent reply cannot be redrafted by an agent
  PASS  mcp: response_stats carries the NPS summary (got array (
  0 => 'responses',
  1 => 'promoters',
  2 => 'passives',
  3 => 'detractors',
  4 => 'score',
), want array (
  0 => 'responses',
  1 => 'promoters',
  2 => 'passives',
  3 => 'detractors',
  4 => 'score',
))

== 3.2 · automatic asks (inbound triggers) ==
  PASS  trigger: a correct X-<App>-Signature passes
  PASS  trigger: a wrong signature, a missing one, or a signature of another body fails
  PASS  trigger: with no secret the secret address alone admits the delivery
  PASS  trigger: the event comes from X-<App>-Event, else the body (got array (
  0 => 'payment.recorded',
  1 => 'job.completed',
), want array (
  0 => 'payment.recorded',
  1 => 'job.completed',
))
  PASS  trigger: a customer is found inside customer/data objects, first + last name joined (got array (
  'name' => 'Ava T',
  'email' => 'ava@x.example',
  'phone' => '+4477',
  'staff' => 'Dr Patel',
  'ref' => 'J-9',
), want array (
  'name' => 'Ava T',
  'email' => 'ava@x.example',
  'phone' => '+4477',
  'staff' => 'Dr Patel',
  'ref' => 'J-9',
))
  PASS  trigger: an invalid email is dropped (got '', want '')
  PASS  trigger: generic -> ask (got 'ask', want 'ask')
  PASS  trigger: an event outside the chosen list is ignored (got 'ignore', want 'ignore')
  PASS  trigger/jobora: job.completed asks the customer, email read from Jobora's API, credited to the assigned tech (got array (
  0 => 'ask',
  1 => 'kim@jobs.example',
  2 => 'Dan Tech',
  3 => 'job-42',
), want array (
  0 => 'ask',
  1 => 'kim@jobs.example',
  2 => 'Dan Tech',
  3 => 'job-42',
))
  PASS  trigger/jobora: job.created is not an ask (got 'ignore', want 'ignore')
  PASS  trigger/slotly: a booking is asked about the morning after it ends (got array (
  0 => 'ask',
  1 => '2026-10-03',
  2 => 'lee@appt.example',
), want array (
  0 => 'ask',
  1 => '2026-10-03',
  2 => 'lee@appt.example',
))
  PASS  trigger/slotly: a cancellation withdraws the ask (got 'withdraw', want 'withdraw')
  PASS  trigger/slotly: a booking already canceled at Slotly withdraws (got 'withdraw', want 'withdraw')
  PASS  trigger/slotly: a payment asks at once (no due date) (got '', want '')
  PASS  trigger/slotly: with no Slotly address or key it says what to add (got 'ignore', want 'ignore')
  PASS  trigger/apply: a new customer is added and asked, queued for the day after the appointment, credited to the stylist (got array (
  0 => 'created',
  1 => 'queued',
  2 => '2026-10-03',
  3 => 'Sam Reid',
  4 => 'lee@appt.example',
), want array (
  0 => 'created',
  1 => 'queued',
  2 => '2026-10-03',
  3 => 'Sam Reid',
  4 => 'lee@appt.example',
))
  PASS  trigger/apply: the ask rides the trigger's own send window, which releases itself
  PASS  trigger/apply: the same customer is not asked twice within the dedupe window (got 'duplicate', want 'duplicate')
  PASS  trigger/apply: a reschedule moves the queued ask (got array (
  0 => 'rescheduled',
  1 => '2026-10-09',
), want array (
  0 => 'rescheduled',
  1 => '2026-10-09',
))
  PASS  trigger/apply: a cancellation removes the queued ask (got array (
  0 => 'withdrawn',
  1 => 0,
), want array (
  0 => 'withdrawn',
  1 => 0,
))
  PASS  trigger/apply: no email and no phone -> nothing is created (got 'no_contact', want 'no_contact')
  PASS  trigger/apply: "at once" goes through the product's own sender (manual mode here: waiting to be copied) (got array (
  0 => 'created',
  1 => 'pending',
  2 => 'Dr Okoye',
  3 => true,
), want array (
  0 => 'created',
  1 => 'pending',
  2 => 'Dr Okoye',
  3 => true,
))
  PASS  trigger: the url key and the remote API key never leave in the shareable backup

== 3.2 · cookieless public routes, permissions, schema ==
  PASS  cookieless: the widget, its script and JSON, referral links and inbound triggers start no session
  PASS  perm: approving a testimonial is the owner's (a member cannot), and the triggers need settings.edit
  PASS  gdpr: a subject-access export includes their testimonials, and erasure blanks the quote and the name
  PASS  schema: an installer-shaped install has every 3.2 column and table
  PASS  schema: v32() is idempotent (a second migrate changes nothing and throws nothing)

== 3.2 · the demo shows every new feature working ==
  PASS  demo: widget, NPS and the referral link are on (got array (
  0 => 1,
  1 => 1,
  2 => 1,
), want array (
  0 => 1,
  1 => 1,
  2 => 1,
))
  PASS  demo: three approved testimonials and one waiting (got array (
  0 => 3,
  1 => 1,
), want array (
  0 => 3,
  1 => 1,
))
  PASS  demo: every sent request names the clinician, three of them (got 3, want 3)
  PASS  demo: NPS from six answers is 17 (got array (
  0 => 6,
  1 => 17,
), want array (
  0 => 6,
  1 => 17,
))
  PASS  demo: four visits from referral links (got 4, want 4)
  PASS  demo: a reply draft from the practice's own AI assistant waits in the inbox (got 'agent:assistant@purple-dental.example', want 'agent:assistant@purple-dental.example')
  PASS  demo: a Slotly trigger with four logged deliveries (got array (
  0 => 1,
  1 => 4,
), want array (
  0 => 1,
  1 => 4,
))
  PASS  demo: the widget renders the approved quotes with the all-ratings average
  PASS  http: signed in as the owner
  PASS  http: GET /performance renders (200, no PHP notice, shows "Dr Patel")
  PASS  http: GET /testimonials renders (200, no PHP notice, shows "Website widget")
  PASS  http: GET /triggers renders (200, no PHP notice, shows "/hooks/in/fa501963c49ceca5e7e2e231dcd79e48")
  PASS  http: GET /feedback renders (200, no PHP notice, shows "Send by email")
  PASS  http: GET /settings renders (200, no PHP notice, shows "Ask the NPS question")
  PASS  http: GET /requests/new renders (200, no PHP notice, shows "Team member who did the job")
  PASS  http: team performance lists Sam Reid (named on the ask) beside Dr Patel (served by)
  PASS  http: widget: 404 until the owner switches it on
  PASS  http: widget.json: 404 until switched on
  PASS  http: rating page: takes the owner's brand color (it read a setting that does not exist, so it never did)
  PASS  http: rating page (5 stars): the review link, then "May we quote you?"
  PASS  http: rating page (2 stars): the review link and the private message, no quote offer
  PASS  http: quote without the consent box: nothing stored
  PASS  http: quote with consent: stored, waiting for approval
  PASS  http: quote refused for a 2-star rating (the form is not offered there)
  PASS  http: the waiting quote is listed for the owner
  PASS  http: approve: 302 back to the list
  PASS  http: approved quotes offer one-click sharing (X, Facebook, LinkedIn)
  PASS  http: widget settings saved
  PASS  http: widget: 200 with the approved quote and the all-ratings average (3.5 from 2)
  PASS  http: widget: sets no cookie
  PASS  http: widget: cacheable for five minutes
  PASS  http: widget.js: JavaScript that frames this install's /widget, no cookie
  PASS  http: widget.json: open to any site (CORS) with the approved items
  PASS  http: settings: the referral link cannot be switched on without a landing page
  PASS  http: rating page (5 stars): the NPS question and the customer's own referral link
  PASS  http: referral link: redirects to the landing page with ?ref=, sets no cookie
  PASS  http: referral link: an unknown code is a 404
  PASS  http: NPS: answered once (the question disappears; a second answer is ignored)
  PASS  http: team performance shows the NPS (1 promoter -> 100)
  PASS  http: inbox: a reply draft is saved and shown for sending
  PASS  http: inbox: "Send by email" with no SMTP says so and keeps the text
  PASS  http: trigger: an unknown address is a 404
  PASS  http: trigger: a bad signature is refused (401) and nothing is created
  PASS  http: trigger: a signed delivery creates the ask (200, "created"), with no cookie
  PASS  http: trigger: the same customer again is a duplicate, answered 200 so the sender does not retry
  PASS  http: triggers page: the deliveries are logged where the owner can see them
  PASS  http: a member gets 403 approving or hiding a testimonial
  PASS  http: a member gets 403 on the triggers page
  PASS  http: a member can read the testimonials but sees no approve buttons
  PASS  http: the server log carries no PHP warning or fatal
  PASS  http: the walk made every check it planned (42)
  PASS  api roles: POST /api/contacts refuses every role without the permission (403) and admits the rest
  PASS  api roles: POST /api/requests refuses every role without the permission (403) and admits the rest
  PASS  import dry run: dry run: 1 ready, the row with no email or phone would be skipped with the import's reason, nothing written
  PASS  import dry run: the import does exactly what the dry run said
  PASS  import dry run: the upload's dry run goes through the same row rule, not the validate-only mode

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

== 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 Revup 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 Revup 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: the owner's "keep" count is honored (2 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 Revup, 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 8 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 (26 tables dumped)
ALL 561 TESTS PASSED (0 failed)

← Back to Revup · Manual · Quickstart · API