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

Slotly Test run, as shipped in the download

434 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 with php tests/run.php. Run it yourself after you download.

[DB: SQLite]

== 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  canceled 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 honored
  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: anonymize 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 4.1.3 at boot + migrate-at-boot present
  PASS  v3: service worker cache is versioned 4.1.3
  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  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  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 neutralized
  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.confirmed',
  2 => 'appointment.created',
  3 => 'appointment.rescheduled',
  4 => 'payment.recorded',
), want array (
  0 => 'appointment.cancelled',
  1 => 'appointment.confirmed',
  2 => 'appointment.created',
  3 => 'appointment.rescheduled',
  4 => '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  demo seed: seed_today_is_booked
  PASS  demo seed: seed_every_past_day_has_visits
  PASS  demo seed: seed_sundays_are_ava_and_sofia_10_to_16
  PASS  demo seed: seed_sunday_hours_for_two_staff
  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 (51 checked)
  PASS  backup JSON: an administrator gets a backup, with every credential column planted (17)
  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

== 3.2: deposit to confirm, cancellation policy, no-show rule ==
  PASS  hold: a deposit-to-confirm service with a deposit needs the deposit (got 'deposit', want 'deposit')
  PASS  hold: the same service with no deposit due needs nothing extra (got '', want '')
  PASS  hold: an ordinary service needs nothing extra (got '', want '')
  PASS  hold: an unpaid hold lapses hold_hours from now (got '2026-11-09 08:00:00', want '2026-11-09 08:00:00')
  PASS  hold: never later than the appointment itself (got '2026-11-08 13:00:00', want '2026-11-08 13:00:00')
  PASS  hold: a held booking keeps its slot (nobody else can take it)
  PASS  hold: nothing lapses before hold_until (got array (
), want array (
))
  PASS  hold: at hold_until the unpaid hold is canceled, its seat freed and the reason noted
  PASS  hold: the lapsed slot is bookable again
  PASS  hold: a second sweep releases nothing twice (got array (
), want array (
))
  PASS  hold: a part-paid deposit does not confirm
  PASS  hold: the full deposit confirms the booking and ends the hold
  PASS  hold: a confirmed booking is never lapsed (got array (
), want array (
))
  PASS  no-show: counted per customer (got 2, want 2)
  PASS  no-show rule off (0): nothing extra (got '', want '')
  PASS  no-show rule: a service with a deposit asks for it first (got 'deposit', want 'deposit')
  PASS  no-show rule: a service without one waits for approval (got 'approval', want 'approval')
  PASS  no-show rule: a customer under the limit books as before (got '', want '')
  PASS  policy: none set, no tick needed
  PASS  policy: set, and the box not ticked, refuses
  PASS  policy: set and ticked, accepts
  PASS  policy: the booking keeps the exact text accepted, and when
  PASS  booking page: the policy is checked before the booking exists, the hold decides the status and is stamped after
  PASS  slot reads and bookings lapse expired holds first (no cron needed)
  PASS  recording the deposit confirms a held booking, emails the customer and sends appointment.confirmed
  PASS  confirming by hand ends a hold too
  PASS  screens: deposit to confirm per service; hold hours, no-show limit and policy in Settings
  PASS  booking page: the policy tick box and the hold wording reach the customer

== 3.2: text reminders through the owner's own gateway ==
  PASS  sms: numbers are normalized (got array (
  0 => '+447700900123',
  1 => '+447700900123',
  2 => '4155550110',
  3 => '',
), want array (
  0 => '+447700900123',
  1 => '+447700900123',
  2 => '4155550110',
  3 => '',
))
  PASS  sms: JSON with a Bearer key by default
  PASS  sms: Twilio's shape — form-encoded To/From/Body with Basic auth
  PASS  sms: an http:// gateway is never used
  PASS  sms: a 2xx from the gateway counts as sent
  PASS  sms: the first run texts the 20-hour and the 1.5-hour bookings, once each (got 2, want 2)
  PASS  sms: nobody who did not tick the box, and no canceled booking, is texted
  PASS  sms: the booking inside both windows got only the nearer reminder
  PASS  sms: the text names the service, the time and an absolute manage link
  PASS  sms: a second run at the same time sends nothing (got 0, want 0)
  PASS  sms: two hours before, the second reminder goes (got 1, want 1)
  PASS  sms: rescheduling resets the reminders (got 0, want 0)
  PASS  sms: the customer can stop texts from their own booking page
  PASS  sms: the tick box only appears when texts are on, and the booking stores it only with a real number
  PASS  cron: one line lapses holds, sends emails and texts
  PASS  settings: the gateway key is write-only and the address https-only
  PASS  settings: a test text shows what the gateway answered

== 3.2: waitlist ==
  PASS  waitlist: a bad email, a past day and a day beyond the booking window are refused
  PASS  waitlist: a day the chosen person does not work is refused; anyone else working keeps it open
  PASS  booking page: the waitlist is offered only on a day somebody works
  PASS  waitlist: joining works and the same person on the same day is not added twice
  PASS  waitlist: a canceled seat with Holda goes to the first person waiting for anyone, not to someone waiting only for another staff member
  PASS  waitlist: the offer is texted to a person who asked, with the booking link
  PASS  waitlist: the link opens the booking page on that service and day
  PASS  waitlist: a past booking (a no-show) offers nothing
  PASS  waitlist: the offer token finds the entry; booking with it closes the entry and spends the token
  PASS  waitlist: a junk token is refused before any query
  PASS  waitlist: one address holds at most 5 open entries
  PASS  waitlist: every door that cancels offers the seat (customer, staff, lapsed hold)
  PASS  waitlist: the public join is rate-limited and the booking page wires it
  PASS  waitlist: an admin page lists it, viewers read, staff offer and remove

== 3.2 pass B: repeating appointments ==
  PASS  series: the first booking plus every free week in the booking window, same time each week
  PASS  series: a week that clashes is skipped and reported, never double-booked
  PASS  series: filling again books nothing twice (got array (
), want array (
))
  PASS  series: with no end date, new weeks are booked as they come into the window
  PASS  series: every 2 weeks, until a date
  PASS  series: ending it can cancel the upcoming ones (their seats reopen), and an ended series books nothing more
  PASS  series: across the clock change the local time stays 10:00
  PASS  series: the new-appointment form offers it and the appointment page can end it

== 3.2 pass B: a staff member's own calendar blocks the booking page ==
  PASS  ics: busy blocks from UTC, TZID, all-day, duration, weekly repeats (minus the exception, with the moved one), folded lines — not canceled or free events (got array (
  0 => 
  array (
    0 => '2026-11-02 15:00:00',
    1 => '2026-11-02 16:00:00',
  ),
  1 => 
  array (
    0 => '2026-11-03 09:00:00',
    1 => '2026-11-03 11:00:00',
  ),
  2 => 
  array (
    0 => '2026-11-04 12:00:00',
    1 => '2026-11-04 13:00:00',
  ),
  3 => 
  array (
    0 => '2026-11-05 00:00:00',
    1 => '2026-11-06 00:00:00',
  ),
  4 => 
  array (
    0 => '2026-11-09 08:00:00',
    1 => '2026-11-09 08:30:00',
  ),
  5 => 
  array (
    0 => '2026-11-18 17:00:00',
    1 => '2026-11-18 18:00:00',
  ),
  6 => 
  array (
    0 => '2026-11-25 12:00:00',
    1 => '2026-11-25 13:00:00',
  ),
), want array (
  0 => 
  array (
    0 => '2026-11-02 15:00:00',
    1 => '2026-11-02 16:00:00',
  ),
  1 => 
  array (
    0 => '2026-11-03 09:00:00',
    1 => '2026-11-03 11:00:00',
  ),
  2 => 
  array (
    0 => '2026-11-04 12:00:00',
    1 => '2026-11-04 13:00:00',
  ),
  3 => 
  array (
    0 => '2026-11-05 00:00:00',
    1 => '2026-11-06 00:00:00',
  ),
  4 => 
  array (
    0 => '2026-11-09 08:00:00',
    1 => '2026-11-09 08:30:00',
  ),
  5 => 
  array (
    0 => '2026-11-18 17:00:00',
    1 => '2026-11-18 18:00:00',
  ),
  6 => 
  array (
    0 => '2026-11-25 12:00:00',
    1 => '2026-11-25 13:00:00',
  ),
))
  PASS  ics: only https (or webcal) public addresses are read
  PASS  ics: a synced event blocks that time on the booking page
  PASS  ics: reading it again replaces the blocks, never adds duplicates (got 1, want 1)
  PASS  ics: a failed read keeps the last good blocks and says why
  PASS  ics: removing the address removes its blocks
  PASS  ics: the address is write-only, admin-only, redacted from the JSON backup, and read by the cron

== 3.2 pass B: booking questions and a video room per service ==
  PASS  intake: one question per line, "*" = required (got array (
  0 => 
  array (
    'label' => 'How did you hear about us?',
    'required' => false,
  ),
  1 => 
  array (
    'label' => 'Allergies',
    'required' => true,
  ),
), want array (
  0 => 
  array (
    'label' => 'How did you hear about us?',
    'required' => false,
  ),
  1 => 
  array (
    'label' => 'Allergies',
    'required' => true,
  ),
))
  PASS  intake: a required answer is needed; a blank optional one is dropped; lists and floods are refused
  PASS  intake: the booking page asks before the booking exists and stores the answers with their questions
  PASS  intake: answers show on the appointment, in the owner's email and in the bookings CSV
  PASS  video: an https room per service reaches the confirmation, the reminder, the booking page and the calendar feed

== 3.2: links in emails and texts are absolute ==
  PASS  abs_url: with no request (the cron), the address the admin was last opened at is used (got 'https://book.example.com/b/X?t=y', want 'https://book.example.com/b/X?t=y')
  PASS  emails: every manage link is absolute
  PASS  the admin address is recorded on signed-in pages only

== 3.2 (overflow): the no-show fee through the owner's payment link ==
  PASS  no-show fee: only a no-show can be asked for it
  PASS  no-show fee: the deposit already paid comes off the fee (got 1500, want 1500)
  PASS  no-show fee: one email to the customer with the amount, the policy they accepted and the payment link
  PASS  no-show fee: the request is recorded on the appointment
  PASS  no-show fee: asked once, never twice
  PASS  no-show fee: an email that cannot be sent records nothing, so it can be tried again
  PASS  no-show fee: refused without a payment link, with the fee off, or when the deposit already covers it
  PASS  no-show fee: the button is routed, needs payment.record and CSRF, and the setting is saved

== 3.3 (customer voice round 4): a cap on appointments at the same time; client categories ==
  PASS  cap: with no limit set, the other person is still offered 10:00
  PASS  cap: at one appointment at a time, 10:00 (and anything overlapping it) is no longer offered by anyone; 11:00 is
  PASS  cap: booking the full time for someone else is refused, through the engine
  PASS  cap: at two, a second appointment fits and a third is refused
  PASS  cap: a group class counts once — more seats in it are still sold, another appointment beside it is not
  PASS  cap: moving onto a full time is refused; moving into a free time, or within its own place, is not
  PASS  cap: book() and move() re-check it under the lock, holding the settings row so two staff cannot take the last place at once
  PASS  categories: the ones in use, most used first
  PASS  categories: the appointment carries its customer's category
  PASS  categories: saved from the customer form, filter the customer list, and show on the list and the appointment

== fix wave: a staff key reaches only its own appointments (MCP and REST, like the web) ==
  PASS  staff scope: mcp_list_is_the_staff_users_own
  PASS  staff scope: mcp_list_for_an_admin_is_everyone
  PASS  staff scope: mcp_detail_of_someone_elses_is_refused
  PASS  staff scope: mcp_reschedule_of_someone_elses_is_refused_and_nothing_moves
  PASS  staff scope: mcp_reschedule_of_their_own_still_works
  PASS  staff scope: mcp_day_report_is_the_staff_users_own_day
  PASS  staff scope: GET /api/v1/appointments with a staff key lists only their own; an admin key lists all
  PASS  staff scope: GET /api/v1/appointments/{id} of someone else's answers 404 to a staff key, 200 to an admin key
  PASS  staff scope: at the desk, a staff user cannot change or print someone else's appointment by its id either

== 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 /admin/updates is updates_page()
  PASS  updates: route post /admin/updates/key is updates_key_save()
  PASS  updates: route post /admin/updates/check is updates_check()
  PASS  updates: route post /admin/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 Slotly 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 Slotly 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 /admin/sample-data/remove is sample_data_remove()
  PASS  A2: route post /admin/restore/backups is backups_save()
  PASS  A2: route post /admin/restore/backup-now is backup_now()
  PASS  A2: route get /admin/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: 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)

== 4.0 A3: switch from another tool ==
  PASS  A3: the switch checks ran to the end
  PASS  A3: the map loads, none refused
  PASS  A3: every shipped map has its real export as a fixture, reports exactly what is pinned beside it, imports exactly that, and a second import adds nothing (1 shipped)
  PASS  A3: the real exports kept as fixtures carry no address outside example.com (their personal data was replaced)
  PASS  A3: the people who booked are customers, with their full names; the one whose meeting was canceled too
  PASS  A3: someone who booked twice comes across once: the second meeting is a repeat, not a problem
  PASS  A3: the customer row the plain import shares: a bad email, an email already here and one earlier in the same file are named, and a check writes nothing
  PASS  A3: the plain CSV import still writes and leaves import_ref empty
  PASS  A3: a waiting report whose upload was swept is dropped: no Import button on stale data
  PASS  A3: a report with nothing to import needs no file: it is shown once, then gone
  PASS  A3: each real export's report reads right on the page, both forms posting to Slotly's own route (1)
  PASS  A3: all 10 switch checks reported
  PASS  A3: index.php loads the SDK and the wiring, and POST /admin/customers/import/switch is switch_upload()
  PASS  A3: Database::migrate() gives customers their import_ref column (in code, not in a comment)
  PASS  A3: switch_upload() opens with the customer import's own permission and the CSRF check
  PASS  A3: the plain CSV import runs the same customer row, and its dry run runs the row's checks (CsvImport's own dry mode never calls it)
  PASS  A3: the import page is given the maps, the waiting report and its own route, and shows the card only when a map exists
  PASS  A3: import-maps/ holds its README and the maps, nothing else; each map's real export and pinned report are in tests/fixtures

== 4.1: card deposits and prepayment at booking (the owner's own Stripe) ==
  PASS  card: off by default (switch off, no keys, every service "No online payment"), and nothing is due by card
  PASS  card: test-mode keys work; the key shapes are checked
  PASS  card: due now is the service deposit, the full price, or nothing
  PASS  card: the time to pay stays between 30 and 60 minutes
  PASS  card: the booking is held as pending and the customer goes to a Stripe page made with the owner's key
  PASS  card: the page asks for the deposit, names the booking, and comes back to the booking page
  PASS  card: Stripe's page expiry is set explicitly and the hold ends at the same moment (30 minutes + 1)
  PASS  card: the held slot cannot be booked by anyone else
  PASS  card: a delivery whose signature does not verify (wrong secret, changed or padded body, too old, none) is refused with 400 and writes nothing
  PASS  card: a signed, paid event confirms the booking, ends the hold and records the payment in the payments table
  PASS  card: the confirmation goes out only now, to the customer and the business
  PASS  card: the confirmation email says what was paid by card and what is left
  PASS  card: a retried event and the session's second event change nothing (paid exactly once)
  PASS  card: back from Stripe before the webhook, the booking page waits for Stripe and the booking stays held
  PASS  card: full prepayment confirms when the whole price arrives, and the page then shows it paid in full
  PASS  card: unpaid, wrong currency, a booking that does not match and other events record nothing (and Stripe gets a 200)
  PASS  card: less than the hold asked for is recorded but does not confirm
[slotly stripe] SQLSTATE[HY000]: General error: 1 no such table: card_payments
  PASS  card: a failure while recording answers 500 and gives the event back; Stripe's retry then records it once
  PASS  card: a Stripe error leaves the slot held and tells the customer to try again from the booking page
  PASS  card: a new page closes the previous one at Stripe, and a booking may ask for three pages at most
  PASS  card: a page is refused for a booking not waiting for payment, and when card payment is switched off
  PASS  card: an unpaid card hold is kept five minutes past its end for a payment on its way, then released and the slot reopens
  PASS  card: when Stripe says the current page expired the hold is released at once; an older page closing changes nothing
  PASS  card: a payment after the hold was released is flagged "refund due", not counted, and the slot stays with whoever took it
  PASS  card: the customer gets an apology saying the money will be refunded, and the business a notice to refund it in Stripe
  PASS  card: a second payment for a booking that was already paid is flagged "refund due" too
  PASS  card: a booking whose hold was released is not confirmed by a payment recorded at the same moment
  PASS  card: a service with "No online payment" books as before (nothing due by card, its own deposit rules apply)
  PASS  card: the booking door holds first, then sends the customer to Stripe, and emails only for bookings without card payment
  PASS  card: the webhook reads the raw request body and its Stripe-Signature header, with no session or CSRF
  PASS  card: routes for the webhook, the pay button and "Mark refunded" are registered, and Pay.php is loaded
  PASS  card: "Mark refunded" needs the payment.record permission, the CSRF token and the staff member's own appointment
  PASS  card: canceling a held booking closes its open Stripe page (customer and staff)
  PASS  card: Settings takes the switch, write-only keys, currency and time to pay; each service chooses its online payment
  PASS  service form: staff already assigned to a service stay listed (and so stay assigned on save) even without a login
  PASS  card: the JSON backup leaves both Stripe secrets out

== 4.1: the sample staff take bookings but can never sign in ==
  PASS  sample staff: bookable while they are the sample's (inside its id range, password still the lock), never otherwise
  PASS  sample staff: a switched-off member of staff with a real password stays off the booking page
  PASS  sample staff: they still cannot sign in (no password matches, and login asks for active = 1)
  PASS  sample staff: a time with them can be booked (the slot check uses the same rule)
  PASS  sample staff: once given a real password, the ordinary rule applies again (active decides)
  PASS  sample staff: the booking engine, the waitlist and the calendar all use the same rule
  PASS  no-show fee: the confirm sits on the button, so the form submits after it (no <form data-confirm> left on the page)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 9 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 (29 tables dumped)
  PASS  2FA login · two-factor on: the right password alone opens no session
  PASS  2FA login · ...it parks the account for the code step
  PASS  2FA login · ...the code page asks for the code
  PASS  2FA login · ...the admin pages stay shut while the code is outstanding (a signed-in session sees them)
  PASS  2FA login · a wrong code is refused and opens no session
  PASS  2FA login · the right code opens the session and clears the parked account
  PASS  2FA login · the code step with nothing parked opens no session
  PASS  2FA login · two-factor off: the password still signs in at once
  PASS  public booking guard · the booking page itself (Origin = this site) still books
  PASS  public booking guard · a caller with no Origin header (a server, as API.md documents) still books
  PASS  public booking guard · a browser post from another site is refused with 403
  PASS  public booking guard · ...and books nothing, writes no customer
  PASS  public booking guard · an "Origin: null" post is refused with 403
  PASS  public booking guard · a look-alike host (this site's name as a prefix) is refused
  PASS  public booking guard · ...also when the browser marks it Sec-Fetch-Site: cross-site
  PASS  public booking guard · behind a proxy that rewrites the Host header, the browser's own Sec-Fetch-Site: same-origin still books
  PASS  public booking guard · the hidden honeypot field filled: refused (422), books nothing, writes no customer
  PASS  public booking guard · thirty posts from one address in ten minutes all reach the booking (422 for no service)
  PASS  public booking guard · the thirty-first post from that address gets 429 and books nothing
  PASS  public booking guard · ...while another address still books
  PASS  public booking guard · behind Cloudflare with no trusted_proxies, 30 different visitors share one bucket: the next visitor gets 429
  PASS  public booking guard · ...with Cloudflare's ranges in trusted_proxies (the manual's recipe), the same visitor counts on their own and books
  PASS  public booking guard · the booking page carries the hidden honeypot field and booking.js posts it
================================================
  434 passed, 0 failed
================================================

← Back to Slotly · Manual · Quickstart · API