Slotly · Test run · run 2026-09-25
Slotly Test run, as shipped in the download
169 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]
== 1. Working hours & slot generation ==
PASS slot count Mon 9-17 30min/15step (got 31, want 31)
PASS first slot 09:00 (got '09:00', want '09:00')
PASS last slot 16:30 (got '16:30', want '16:30')
== 2. Timezone / DST correctness ==
PASS 09:00 EST -> 14:00 UTC (got '2026-03-02 14:00:00', want '2026-03-02 14:00:00')
PASS 09:00 EDT -> 13:00 UTC (after DST) (got '2026-03-09 13:00:00', want '2026-03-09 13:00:00')
PASS 02:30 on spring-forward day does not exist (null)
PASS slots still 31 after DST week (got 31, want 31)
== 3. Booking removes the slot ==
PASS book 10:00 succeeds
PASS 10:00 slot now gone
PASS 09:45 blocked (overlaps 10:00-10:30)
PASS 10:15 blocked (overlaps 10:00-10:30)
PASS 09:30 still free (ends 10:00, no overlap)
PASS three candidate starts consumed by one 30m booking (got 28, want 28)
== 4. Overlap prevention ==
PASS 10:15 overlapping slot rejected
PASS exact double-book 10:00 rejected
== 5. Buffers ==
PASS book Color 11:00 (buffer 15 after)
PASS 11:30 blocked by 15m buffer
PASS 11:45 free after buffer
== 6. Lead time & max advance ==
PASS 48h lead time hides Monday slots (got 0, want 0)
PASS beyond max advance -> no slots (got 0, want 0)
== 7. Blackout / time off ==
PASS 13:00 removed by time off
PASS 13:30 removed by time off
PASS 12:45 kept (before time off... but 12:45+30=13:15 overlaps -> removed)
PASS 14:00 kept (after time off)
== 8. Group capacity ==
PASS group seat 1 ok
PASS group seat 2 ok
PASS group seat 3 ok
PASS group seat 4 rejected (capacity 3)
PASS overlapping different-start group slot blocked
== 9. Cancel releases the seat ==
PASS after cancel, a seat reopens
PASS cancelled 10:00 haircut slot reopens
== 10. Recurrence across weekdays ==
PASS Sunday has no slots (not in working hours) (got 0, want 0)
PASS Tuesday has slots (weekly recurrence)
== 11. Reschedule (atomic move) ==
PASS book 14:00
PASS move to overlapping-adjacent 14:15 succeeds (self excluded)
PASS moved start persisted (got '2026-03-02 19:15:00', want '2026-03-02 19:15:00')
PASS same row id -> payment history intact
PASS reminder re-armed after move
PASS slot_key updated to new time
PASS move into conflicting time rejected
PASS original booking untouched after failed move (got '2026-03-02 19:15:00', want '2026-03-02 19:15:00')
PASS slot_key still held after failed move
== 12. Oversized customer input is capped (MySQL strict-mode 500 guard) ==
PASS cap_len trims + caps name to 160 (got 160, want 160)
PASS cap_len caps email to 190 (got 190, want 190)
PASS cap_len caps phone to 60 (got 60, want 60)
PASS cap_len leaves normal value untouched (got 'Jane Doe', want 'Jane Doe')
PASS capped name stored within column width (got 160, want 160)
PASS capped phone stored within column width (got 60, want 60)
== 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 both slotly layouts (public booking + admin) link the manifest + register the SW
PASS v3: migrate created api_keys/webhooks/invites/feed_tokens/saved_views/audit_log (idempotent ×3)
PASS v3: users gained totp_secret + totp_recovery
PASS v3: settings gained oidc_* + backup_token_hash + backup_keep
PASS v3: viewer is read-only (refused appointment.create, allowed *.view)
PASS v3: staff runs the desk but not the till settings
PASS v3: admin wildcard covers team.manage + audit.view + customer.delete
PASS v3: an unknown role is refused everything
PASS v3: last-admin guard math
PASS v3: role normalize keeps slotly vocabulary
PASS v3: every role the team screen offers can sign in (one list, not two)
PASS v3: viewer gets through the door and is still refused every write
PASS v3: invite token verifies + expiry honoured
PASS v3: a live key resolves
PASS v3: deactivating the owner kills the key (same query the API door runs)
PASS v3: revoking the key kills it too
PASS v3: Auth attempt+user both carry AND active = 1
PASS v3: Api::requireKey joins u.active = 1
PASS v3: exactly one slotly_backup_redact definition
PASS v3: manual + scheduled backup doors both use the one list
PASS v3: redaction covers password/TOTP/token hashes + webhook secret + cancel_token
PASS v3: audit write + filtered read
PASS v3: audit detail never stores a secret it was told to redact
PASS v3: audit_log is append-only — no UPDATE/DELETE on it anywhere in the product
PASS v3: MCP write tools call Schedule::book and Schedule::move (never a re-implementation)
PASS v3: MCP tool 'list_services' registered
PASS v3: MCP tool 'list_staff' registered
PASS v3: MCP tool 'availability' registered
PASS v3: MCP tool 'list_appointments' registered
PASS v3: MCP tool 'appointment_detail' registered
PASS v3: MCP tool 'book_appointment' registered
PASS v3: MCP tool 'reschedule_appointment' registered
PASS v3: MCP tool 'day_report' registered
PASS v3: DELIBERATE refusals — no cancel, delete or payment tool exists for agents
PASS v3: MCP write gates use the same permission map as the UI
PASS v3: TOTP code verifies and a wrong code does not
PASS v3: ten recovery codes
PASS v3: webhook events are slotly domain events
PASS v3: deliveries are signed X-Slotly-Signature
PASS v3: Pdf core emits a real PDF
PASS v3: Ical core emits VCALENDAR/VEVENT with UID + DTSTART
PASS v3: a foreign product backup is refused
PASS v3: own backup parses
PASS v3: dry-run diff sees exactly one added customer
PASS v3: apply restored the row (and kept the rest)
PASS v3: subject-access export contains the customer + appointment tables
PASS v3: anonymise blanks identity but keeps the row
PASS v3: the agent-door engine refuses the second booking of one seat
PASS v3: reschedule through Schedule::move keeps the row
PASS v3: index wires /mcp, /healthz, /api/v1, audit, invites, restore, SSO
PASS v3: version is 3.1.5 at boot + migrate-at-boot present
PASS v3: service worker cache is versioned 3.1.5
PASS v3: api-settings controller passes users AND the view renders the owner dropdown
PASS v3: CSV dry-run reports but writes nothing
PASS v3: CSV commit imports the good row and reports the bad one line-by-line
PASS v3: README has New in 3.0 + API.md has the MCP agent door
PASS v3: demo auto-login re-seats a non-admin session (not just an empty one)
PASS v3: the demo seat logic is gated on the .demo-mode marker (buyers unaffected)
PASS v3: a 403 renders inside the admin chrome (a bare page reads as a dead click)
PASS v3: every sidebar link is highlighted by the page it opens (no dead-looking nav)
PASS v3: no page claims a nav key the sidebar cannot highlight
PASS v3: Team and Staff each say what they are for (near-identical people tables)
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: calendar + move endpoint + dashboard ==
PASS calendar: overlapping bookings share lanes inside their cluster only (9:00+9:30 → 2 lanes; 10:00 chains the cluster; 12:00 stands alone full-width)
PASS calendar: an empty day yields no lanes and no error
PASS kit: admin layout loads the kit css+js, groups the nav, carries the theme toggle and a who-line
PASS kit: no theme.js shipped; app css is an app layer (no generic palette, accent tokens present, dark accent defined)
PASS kit: App::asset() is versioned by mtime and the service worker revalidates code
PASS kit: no font or script is fetched from another host (fonts bundled)
PASS routes: POST admin/appointments/{id}/move is registered and calls the locked Schedule::move path
PASS move: a booking exists to move
PASS move: Schedule::move accepts a free in-hours slot (the endpoint delegates here, so drag → same guarantees as the public page)
PASS move: a slot outside working hours is refused with a human sentence
PASS dashboard: first-run checklist is computed from the database (services / working hours / bookings), not the session
PASS appointments list: search (q) and the Upcoming tab are wired in the controller
PASS appointment record: rail with status actions, payment bar, customer history and manage link
== v3.1 P5: payment link ==
PASS P5: settings carries payment_link_url (migration ran)
PASS P5: the save path refuses a non-https link with a sentence and clips to 500
PASS P5: the public booking page shows the link ONLY when a link is set, target=_blank noopener, and Slotly still never processes cards
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 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
PASS vet: GET /api/ping is registered, the route the store publishes as the keyless check
PASS vet: api_ping() demands a key, so a keyless caller is refused rather than answered
PASS vet: the refusal names the header to send, verbatim as the vetting page quotes it
PASS vet: /healthz still exists and is still keyless, and is NOT the ping route
-- F2: per-staff feeds, the bookings CSV, webhooks from every door, the demo ribbon --
PASS feeds: a feed for one staff member carries only that person's appointments (got array (
0 => 'FEEDA001',
1 => 'FEEDA002',
), want array (
0 => 'FEEDA001',
1 => 'FEEDA002',
))
PASS feeds: the whole-team feed (0) still carries everybody's (got array (
0 => 'FEEDA001',
1 => 'FEEDB001',
2 => 'FEEDA002',
), want array (
0 => 'FEEDA001',
1 => 'FEEDB001',
2 => 'FEEDA002',
))
PASS feeds: a feed token stores whose diary it is, refuses an unknown staff id, and feed_serve reads it
PASS bookings CSV: one row per booking, local and UTC times, money as decimals
PASS bookings CSV: a customer name that a spreadsheet would run as a formula is neutralised
PASS bookings CSV: routed at /admin/appointments.csv and administrator-only (it carries customer contact details)
PASS webhooks: every event offered in Settings is sent by the app (got array (
0 => 'appointment.cancelled',
1 => 'appointment.created',
2 => 'appointment.rescheduled',
3 => 'payment.recorded',
), want array (
0 => 'appointment.cancelled',
1 => 'appointment.created',
2 => 'appointment.rescheduled',
3 => 'payment.recorded',
))
PASS webhooks: the booking page and the admin form send appointment.created; both cancel doors send appointment.cancelled; the customer's move sends appointment.rescheduled; a recorded payment sends payment.recorded
PASS demo ribbon: on the public booking 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 demo seed: today has bookings (the history stopped at yesterday, so "Today" was always empty)
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 forms: every POST form on a screen reaches a registered route (35 checked)
PASS backup JSON: an administrator gets a backup, with every credential column planted (11)
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
================================================
169 passed, 0 failed
================================================
← Back to Slotly · Manual · Quickstart · API