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 '<b>"x"</b>', want '<b>"x"</b>')
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