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