19 Commits
Author SHA1 Message Date
thatguygriff 1b42d20541 Merge pull request 'Add an account block showing who is signed in' (#156) from feature/142-account-block into main
CI / Build Plugin Zip (push) Successful in 2m48s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m44s
CI / No Debug Code (push) Successful in 2s
Reviewed-on: #156
2026-07-30 01:53:31 +00:00
thatguygriffandClaude Opus 5 ab5212282d Show only the name and email, not who the account books for
CI / Tests (PHP 8.2) (pull_request) Successful in 41s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
The block reports who is signed in and nothing more. Dropping the "Booking
for …" line takes GuardianService with it — it was the only reason the page
had a dependency at all, so AccountPage now constructs with no arguments.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:42:25 -03:00
thatguygriffandClaude Opus 5 6e3affb1cb Add an account block showing who is signed in
[us_account], or the Account block: the signed-in visitor's name, their
email, a Sign out link, and — only when the account books for someone
besides itself — the students it books for. A parent's first question on
seeing "signed in as Grace" is whether this is the account their children's
lessons are on.

Two decisions worth naming.

Signed out with no login page chosen, the block renders nothing. Its whole
subject is the person signed in, which a stranger is not, and a bare "you
are not signed in" in a site header is noise with no way to act on it. With
a login page chosen it offers a Sign in link instead. The editor preview is
populated regardless, so the block is never an invisible box to the person
placing it.

Signing out returns to the chosen login page, or to the current page when
there is none. A block meant for a header should not also navigate someone
somewhere when they use it; the login page wins when configured, because the
page they were on may well be members-only.

The name comes from UserName::format(), so the block never exposes a
username the way display_name can.

Also brings docs/features/editor-blocks.md back in step: it still described
"four shortcodes" and had never listed the family block.

Closes #142

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:42:25 -03:00
thatguygriff 7875cb1bf7 Merge pull request 'Fix main: two signup fixtures use a now-rejected password' (#157) from fix/green-main into main
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.1) (push) Successful in 57s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m51s
CI / Tests (PHP 8.3) (push) Successful in 2m46s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #157
2026-07-30 01:41:29 +00:00
thatguygriffandClaude Opus 5 04cba9702c Fix main: two signup fixtures use a now-rejected password
CI / Tests (PHP 8.2) (pull_request) Successful in 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m46s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Skipped
#154 and #155 each passed on their own branch and broke on landing
together. #154 added two guardian-signup tests using 'password123' as
their fixture; #155 then added PasswordPolicy, which rejects exactly
that. Neither branch ever saw the other's change, because #155 was cut
from main before #154 merged.

Both tests now use the same policy-clearing fixture as the rest of the
file. The deliberate 'password123' in the rejected-passwords provider
stays — that one is the point.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:40:51 -03:00
thatguygriff d554e35d80 Merge pull request 'Validate signup email and password strength' (#155) from feature/150-signup-credential-validation into main
CI / Tests (PHP 8.2) (push) Failing after 43s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Failing after 2m44s
CI / Build Plugin Zip (push) Skipped
CI / Tests (PHP 8.1) (push) Failing after 50s
Reviewed-on: #155
2026-07-30 01:27:05 +00:00
thatguygriffandClaude Opus 5 b5b9a7ac54 Validate signup email and password strength
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
The password was only ever checked for length. It is now checked on both
sides, with each side doing the job it can actually do.

The browser scores it with zxcvbn, through WordPress's own
password-strength-meter script rather than a second opinion of our own, and
refuses to submit below "medium". That is the nuanced test — it knows
Tr0ub4dor&3 is weaker than it looks — but it is advice a client can decline
to take.

Auth\PasswordPolicy runs on the server and is the rule that holds. It does
not try to reproduce a strength score in PHP; it rejects the categorically
bad, which is what a server can check without shipping a dictionary: too
short, a well-known leaked password, fewer than four distinct characters, or
the user's own name or email inside it. No composition rules — NIST advises
against them, and they mostly produce predictable substitutions.

Both thresholds come from the same two constants, handed to JavaScript by
wp_localize_script, so the sides cannot drift into disagreeing about what
was accepted.

The verdict is attached to the field with setCustomValidity() rather than by
disabling a button. The form has up to three submits plus a "Next" that
already gates on checkValidity(), and an invalid field stops all of them
without any of them needing to know why.

Email validation moved ahead of the password check, since the password is
now checked against the email. A blank form therefore reports the email
first, which also matches the order the fields appear in.

Verified the browser half against a controllable scorer: each score band
blocks or allows as intended, the identity list reaches the meter, and the
gate stays open while zxcvbn's dictionary is still loading — the server
covers that window.

Closes #150

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:13:31 -03:00
thatguygriff f0149042cc Merge pull request 'Require a name and birth year for every student' (#154) from feature/148-required-name-and-birth-year into main
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m45s
CI / Build Plugin Zip (push) Successful in 2m45s
Reviewed-on: #154
2026-07-30 00:04:51 +00:00
thatguygriffandClaude Opus 5 1d2f95d388 Require a name and birth year for every student
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
Both fields are marked in their labels the same way a required registration
question is, and enforced on the server whichever form they arrive from:
GuardianService::createChild() and updateChild() now refuse a blank name or
an unusable birth year, and the signup form checks the same rule up front,
before it creates a single user, so a bad block never leaves a
half-registered family behind. normaliseBirthYear() became public and static
so both paths share one definition of what a usable year is.

The signup form cannot lean on the browser here. Its child blocks are hidden
until the parent/guardian box is ticked, and a `required` field inside a
hidden container makes the whole form unsubmittable with no control the user
can reach to fix — the same trap the guardian's own question panel already
sidesteps by disabling rather than hiding. So register.js puts `required` on
and takes it off along with the block itself, and the server is what makes
the rule hold with JavaScript off. The profile screen has no such problem:
its forms are always visible, so the attribute is static there.

One behaviour change beyond the requirement: a child block with anything
typed into it is now reported back instead of dropped. Previously any block
without a name was silently discarded, which would now mean losing a birth
year the guardian had filled in. A wholly untouched spare block — the one
the form always renders for "add another" — is still ignored.

Verified the required-toggling in a headless browser: unticked submits,
ticked blocks an empty block, a cloned block inherits the requirement, and
re-unticking leaves nothing behind to block a non-guardian signup.

Closes #148

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:00:45 -03:00
thatguygriff 2878beb221 Merge pull request 'Collect a birth year instead of a full date of birth' (#153) from feature/147-birth-year into main
CI / Tests (PHP 8.2) (push) Successful in 50s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m47s
CI / Build Plugin Zip (push) Successful in 2m35s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m44s
Reviewed-on: #153
2026-07-29 23:53:37 +00:00
thatguygriffandClaude Opus 5 7e2bba79fe Collect a birth year instead of a full date of birth
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Signup and the profile page now ask for a four-digit year between 1900 and
the current year. Anything else — a short year, a full date, a year in the
future — is discarded rather than stored, so a typo cannot leave a nonsense
age on the record.

The year lives in a new us_birth_year user meta rather than reusing
us_date_of_birth, which would have left one key holding two formats. The old
key is not migrated in bulk. Instead GuardianService handles it in two
halves: birthYear() falls back to the year of the old date when the new key
is absent, so a student added before this change still shows one, and
setBirthYear() deletes the old date on every save.

That deletion is what makes the fallback safe rather than merely tidy.
Without it, clearing the birth year on a student who predates the change
would leave the old date behind for the fallback to read straight back, and
the year could never be cleared at all.

Stored in user meta, so no Schema.php change and no USC_VERSION bump.

Closes #147

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:47:56 -03:00
thatguygriff 3a83decc82 Merge pull request 'Say "student" and "profile" in the UI, not "child" and "family"' (#152) from refactor/144-student-profile-copy into main
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #152
2026-07-29 23:41:55 +00:00
thatguygriffandClaude Opus 5 76caf178f0 Say "student" and "profile" in the UI, not "child" and "family"
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 40s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
Sweep the translatable strings across the frontend templates, the admin
screens, the editor previews and the block inserter entry. Nothing else
moves: the database columns, request parameters, form field names, CSS
classes, the us_family shortcode and the us-scheduler/family block name are
contracts with existing installs and with post content people have already
saved, so renaming them would break sites for no user-visible gain.

Two strings are reworded rather than swapped, because the direct
substitution reads wrong:

- The students list said "Child of Jane" and now says "Managed by Jane".
  "Student of Jane" would read as a teacher's pupil, which is exactly the
  wrong idea in a music studio.
- A managed account is now "a managed student account" rather than "a
  student account", which would not distinguish it from the account holder.

The guardian feature doc gains a short section on the split, so the next
person to work on it does not read the mismatch as drift and "fix" it.

Closes #144

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:34:55 -03:00
thatguygriff 8013d05d68 Merge pull request 'Stop upcoming lesson rows rendering on top of each other' (#151) from fix/149-upcoming-lesson-overlap into main
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / PHPStan (push) Successful in 2m50s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m51s
Reviewed-on: #151
2026-07-29 23:29:18 +00:00
thatguygriffandClaude Opus 5 6b29c0e78e Stop upcoming lesson rows rendering on top of each other
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m48s
CI / Build Plugin Zip (pull_request) Skipped
The panel's row and its two columns are divs with explicit flex rules, but
the text itself still sits in inline elements. A theme is free to take those
out of normal flow, and when it does the date and time land on the lesson
title and the status pill lands on the Cancel button. Pin position, float
and margin on the leaf elements at the same id-level specificity the rest of
the panel already uses, so a theme rule cannot lift them out of the column.

The rows behind "Show all" had the same shape of problem from the other
direction: `[hidden]` is only a UA-stylesheet rule, so the `div {
display: block }` reset that many themes still ship outranks it and the
collapsed rows render anyway. An author `!important` is the only way to win
that particular cascade.

Verified with a headless-browser harness rendering the exact markup
booking.js emits against twelve theme CSS patterns at two widths: before,
five patterns overlapped text or revealed the hidden rows; after, all pass.

Closes #149

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:28:25 -03:00
thatguygriff 7ea6616ba0 Merge pull request 'Trim CLAUDE.md to what the codebase can't tell you' (#141) from docs/trim-claude-md into main
CI / Tests (PHP 8.1) (push) Successful in 50s
CI / Coding Standards (push) Successful in 2m53s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 42s
CI / PHPStan (push) Successful in 2m46s
CI / Tests (PHP 8.3) (push) Successful in 2m49s
CI / Build Plugin Zip (push) Successful in 2m45s
Reviewed-on: #141
2026-07-29 19:41:35 +00:00
thatguygriffandClaude Opus 5 7fdf97b073 Trim CLAUDE.md to what the codebase can't tell you
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
Most of this file described the repo as it was around v1.0: three domain
packages, two database tables, twenty-one classes. There are now eleven
packages, fifteen tables, and well over a hundred classes, so those
sections were not just redundant with `ls` and Schema.php — they were
teaching the wrong shape of the codebase. Same for the CI section, which
had drifted past the build job.

Cut the command list (composer.json has the scripts), the bootstrap
description, the directory tree, the table list, the Key Classes table,
and the CI job summary. Kept every rule the code can't explain on its
own: package-by-domain, no $wpdb outside repositories, capability checks
rather than role names, and the Schema.php version-bump gotcha.

Moved the Brain\Monkey and Mockery gotchas to tests/CLAUDE.md, which
loads only when working under tests/ instead of in every session.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 16:39:43 -03:00
thatguygriff d3843186c0 Merge pull request 'Bump version to 1.3.1' (#140) from release/bump-1.3.1 into main
CI / Tests (PHP 8.2) (push) Successful in 48s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m45s
CI / Tests (PHP 8.1) (push) Successful in 43s
Reviewed-on: #140
2026-07-29 19:28:42 +00:00
Release Bot e44972abe9 Bump version to 1.3.1 and open changelog section 2026-07-29 19:22:36 +00:00
34 changed files with 1461 additions and 267 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"Bash(composer lint *)", "Bash(composer lint *)",
"Bash(tea actions:*)", "Bash(tea actions:*)",
"Bash(tea issue *)", "Bash(tea issue *)",
"Bash(tea label *)" "Bash(tea label *)",
"Bash(composer cs *)"
] ]
} }
} }
+16
View File
@@ -11,6 +11,22 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work. each change under the current top section as you work.
## [1.3.1]
### Added
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
### Security
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
### Changed
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
### Fixed
- Upcoming lesson rows no longer render on top of each other. The row's text sits in inline elements that a theme can pull out of normal flow, which dropped the date and time onto the lesson title and the status pill onto the Cancel button; those elements are now pinned into flow alongside the rest of the panel's theme-proofing. The rows held behind **Show all** also stayed visible under the `div { display: block }` reset that many themes still carry, since `[hidden]` is only a browser default — they are now hidden for real.
## [1.3.0] ## [1.3.0]
### Added ### Added
+4 -88
View File
@@ -4,100 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash
composer install # Install all dependencies
composer test # Run the full test suite (required after every change)
composer lint # PHPStan static analysis
composer cs # PHPCS coding standards check
composer cs:fix # Auto-fix coding standards
# Run a single test file
./vendor/bin/phpunit tests/Unit/Availability/AvailabilityRepositoryTest.php
# Run a single test by name
./vendor/bin/phpunit --filter testInsertCallsWpdbInsertAndReturnsId
```
**Run `composer test` after every code change before considering a task complete.** **Run `composer test` after every code change before considering a task complete.**
## Architecture ## Architecture
### Plugin Bootstrap ### Code organisation
`unsupervised-schedular.php` defines constants (`USC_VERSION`, `USC_PLUGIN_DIR`, `USC_PLUGIN_URL`), registers activation/deactivation hooks, then calls `Plugin::boot()` on `plugins_loaded`. No logic lives in the root file. **Code is organised package-by-domain.** Each domain package under `src/<Domain>/` contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
### Directory Structure
```
src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedular\)
Availability/ — Availability slots: value object, repository, controller, REST endpoint
Booking/ — Lessons/bookings: value object, repository, controller, REST endpoint, shortcode page
Auth/ — Roles, capabilities, login page
Plugin.php — Wires all components together on plugins_loaded
Installer.php — Creates DB tables and roles on activation
Schema.php — CREATE TABLE SQL for dbDelta
AdminMenu.php — Registers wp-admin menu pages
RestRegistrar.php — Registers all REST routes under us-scheduler/v1
ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes
BlockRegistrar.php — Registers Gutenberg dynamic-block wrappers for the shortcodes
BlockPreview.php — Static editor-preview markup for the blocks
templates/ — PHP view files included by controllers/shortcodes
assets/ — CSS and JS (vanilla JS, no build step)
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
Availability/ — Tests for src/Availability/
Booking/ — Tests for src/Booking/
Auth/ — Tests for src/Auth/
docs/features/ — One markdown file per feature describing data model, API, and test locations
```
**Code is organised package-by-domain** (Availability, Booking, Auth). Each domain package contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
### Data Storage ### Data Storage
Two custom database tables (created via `dbDelta` on activation): Custom database tables are created via `dbDelta` on activation; `Schema.php` holds the SQL.
- `{prefix}us_availability` — instructor availability windows
- `{prefix}us_lessons` — booked lessons
All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories. All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories.
### Key Classes
| Class | Responsibility |
|---|---|
| `Plugin` | Wires all components together on `plugins_loaded` |
| `Installer` | Creates DB tables and roles on activation |
| `Schema` | CREATE TABLE SQL strings for dbDelta |
| `AdminMenu` | Registers wp-admin menu pages |
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
| `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes |
| `BlockPreview` | Static editor-preview markup for the blocks |
| `Val` | Runtime coercion of untyped WP boundary values (wpdb rows, REST params, superglobals) |
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
| `Auth\LoginPage` | Renders front-end student login form |
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
| `Availability\AvailabilityRepository` | CRUD for availability slots |
| `Availability\AvailabilityController` | Instructor availability management page |
| `Availability\AvailabilityEndpoint` | REST handlers for availability CRUD |
| `Booking\Lesson` | Immutable value object for a lesson row |
| `Booking\BookingRepository` | CRUD for lesson bookings |
| `Booking\BookingEndpoint` | REST handlers for booking and status updates |
| `Booking\BookingPage` | Renders student booking UI shell (JS takes over) |
| `Booking\LessonController` | Admin and instructor lesson list pages |
### REST API Namespace ### REST API Namespace
All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks. All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks.
### Testing Approach ### Testing Approach
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies. Tests stub WordPress with Brain\Monkey rather than booting a real WP install. The setup and the Brain\Monkey/Mockery API gotchas are in `tests/CLAUDE.md`.
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
**Brain\Monkey API notes:**
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
### Adding a Feature ### Adding a Feature
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently. 0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
@@ -106,10 +29,3 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
3. Add template(s) under `templates/` if needed. 3. Add template(s) under `templates/` if needed.
4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure. 4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure.
5. Run `composer test` — all tests must pass before the feature is complete. 5. Run `composer test` — all tests must pass before the feature is complete.
### CI
Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request:
- **lint** — PHPCS WordPress coding standards
- **static-analysis** — PHPStan level 10
- **test** — PHPUnit on PHP 8.1, 8.2, 8.3
- **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/`
+85 -3
View File
@@ -99,6 +99,36 @@
align-items: center; align-items: center;
} }
/*
* Theme-proofing for the leaf text. The row and its two columns are divs with
* explicit flex rules above, but the text itself still sits in inline elements
* a theme is free to take out of normal flow — an absolutely positioned,
* floated or negatively offset span drops the date/time on top of the title and
* the status pill on top of the Cancel button. Pinning the three properties
* that would have to change keeps the leaves in flow, at the same id-level
* specificity the rules above rely on.
*/
#us-booking-app .us-my-lesson-title,
#us-booking-app .us-my-lesson-when,
#us-booking-app .us-my-lesson-duration,
#us-booking-app .us-my-lesson-who,
#us-booking-app .us-lesson-status {
position: static;
float: none;
margin: 0;
}
/*
* The rows the "Show all" button reveals. `[hidden]` is only a UA-stylesheet
* rule, so any author rule setting a display on div beats it — the html5-reset
* `div { display: block }` is still widespread in themes — and the rows the
* button is meant to gate render anyway. An author !important is the only way
* to win that cascade.
*/
#us-booking-app [hidden] {
display: none !important;
}
#us-booking-app .us-show-all-lessons { #us-booking-app .us-show-all-lessons {
background: transparent; background: transparent;
border: 1px solid #ccc; border: 1px solid #ccc;
@@ -397,8 +427,13 @@
max-width: 100%; max-width: 100%;
} }
/* Whose lesson a row in the upcoming panel is — only shown on a family account. */ /*
.us-my-lesson-who { * Whose lesson a row in the upcoming panel is — only shown on an account that
* books for more than one person. Scoped under #us-booking-app like the rest of
* the panel; as a bare class it was the one rule in the group a theme could
* outrank on a plain span.
*/
#us-booking-app .us-my-lesson-who {
font-weight: normal; font-weight: normal;
opacity: 0.75; opacity: 0.75;
} }
@@ -457,7 +492,7 @@
font-weight: 600; font-weight: 600;
} }
.us-family-child-dob { .us-family-child-birth-year {
font-size: 0.9em; font-size: 0.9em;
opacity: 0.75; opacity: 0.75;
} }
@@ -490,6 +525,53 @@
} }
} }
/*
* The live password verdict under the signup field. Colour is a reinforcement,
* not the message — the text says what is wrong on its own, so this still reads
* correctly to anyone who cannot separate the hues.
*/
.us-password-strength {
display: block;
margin-top: 4px;
font-size: 0.85em;
}
.us-password-strength.is-short,
.us-password-strength.is-weak {
color: #c00;
}
.us-password-strength.is-medium {
color: #7a5c00;
}
.us-password-strength.is-strong {
color: #1a7d2e;
}
/*
* The account panel: who is signed in, and the way out. Sized to sit in a
* header or sidebar, so the rules stay minimal and inherit the theme's type —
* a block that lands in a site header should look like it belongs there.
*/
.us-account p {
margin: 0 0 4px;
}
.us-account-name {
font-weight: 600;
}
.us-account-email {
display: block;
font-size: 0.9em;
opacity: 0.75;
}
.us-account-actions {
margin-top: 8px;
}
/* Shown only in block-editor previews (see BlockPreview). */ /* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note { .us-editor-note {
font-size: 0.85em; font-size: 0.85em;
+28 -3
View File
@@ -284,10 +284,13 @@
}, },
{ {
name: 'us-scheduler/family', name: 'us-scheduler/family',
title: __('Family', 'unsupervised-schedular'), title: __('Profile', 'unsupervised-schedular'),
description: __('Lets a parent or guardian add, edit and remove the children they book lessons for.', 'unsupervised-schedular'), description: __('Lets a parent or guardian add, edit and remove the students they book lessons for.', 'unsupervised-schedular'),
icon: 'groups', icon: 'groups',
keywords: ['family', 'children', 'guardian', 'parent'], // 'family' and 'children' are kept as search terms only — they are
// never displayed, and the block answered to them before it was
// renamed, so anyone reaching for the old word still finds it.
keywords: ['profile', 'students', 'family', 'children', 'guardian', 'parent'],
shortcode: 'us_family', shortcode: 'us_family',
attributes: { attributes: {
loginPageId: { type: 'number', default: 0 }, loginPageId: { type: 'number', default: 0 },
@@ -304,6 +307,28 @@
}) })
), ),
}, },
{
name: 'us-scheduler/account',
title: __('Account', 'unsupervised-schedular'),
description: __('Shows the name and email of whoever is signed in, with a sign out link. Renders nothing for signed-out visitors unless a login page is chosen.', 'unsupervised-schedular'),
icon: 'admin-users',
keywords: ['account', 'sign out', 'log out', 'signed in', 'profile'],
shortcode: 'us_account',
attributes: {
loginPageId: { type: 'number', default: 0 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Signing in and out', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where signing out returns to, and where signed-out visitors are offered a link to sign in. Without one, signing out returns to the current page and signed-out visitors see nothing.', 'unsupervised-schedular'),
defaultLabel: __('Stay on the current page', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
})
),
},
]; ];
blocks.forEach((def) => { blocks.forEach((def) => {
+118
View File
@@ -14,10 +14,113 @@
* block. Ticking the box also takes the guardian's *own* question panel out * block. Ticking the box also takes the guardian's *own* question panel out
* of play — in guardian mode the questions are asked per child, so the * of play — in guardian mode the questions are asked per child, so the
* server ignores those answers and the browser must not demand them. * server ignores those answers and the browser must not demand them.
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
* own `wp.passwordStrength`) and a weak one is refused. The server applies
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
*/ */
(function () { (function () {
'use strict'; 'use strict';
var PASSWORD = window.usSchedulerPassword || {};
/**
* Gate the form on password strength.
*
* The verdict is attached to the field with `setCustomValidity()` rather than
* by disabling the submit button: the form has up to three submits (the plain
* one, the guardian-mode early one, and step two's) plus a "Next" that
* already gates on `checkValidity()`, and an invalid field blocks all of them
* at once without any of them having to know why.
*/
function enhancePassword(form) {
var field = form.querySelector('#us-reg-pass');
var output = form.querySelector('#us-reg-pass-strength');
var strings = PASSWORD.strings || {};
if (!field || !PASSWORD.minScore) {
return;
}
// What the password must not simply repeat back. Mirrors the identity
// check PasswordPolicy makes server-side.
function identity() {
var out = [];
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
for (var i = 0; i < sources.length; i++) {
var value = (sources[i].value || '').trim();
if (value) {
out.push(value);
if (value.indexOf('@') > 0) {
out.push(value.split('@')[0]);
}
}
}
return out;
}
function assess() {
var value = field.value || '';
if (!value) {
report('', '');
return;
}
if (value.length < (PASSWORD.minLength || 8)) {
report(strings.short, 'short');
return;
}
// zxcvbn's dictionary is fetched after load, and wp.passwordStrength
// reports -1 until it arrives. Say nothing and allow the submit in that
// window — the server still checks, and the next keystroke re-runs this
// once the dictionary is in.
if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') {
report('', '');
return;
}
var score = window.wp.passwordStrength.meter(value, identity(), '');
if (score < 0) {
report('', '');
return;
}
if (score >= 3) {
report(strings.strong, 'strong');
} else if (score >= PASSWORD.minScore) {
report(strings.medium, 'medium');
} else {
report(score <= 0 ? strings.veryWeak : strings.weak, 'weak');
}
}
/** Show the verdict, and make it the field's validity at the same time. */
function report(message, level) {
var acceptable = '' === level || 'medium' === level || 'strong' === level;
if (output) {
output.textContent = message || '';
output.className = 'us-password-strength' + (level ? ' is-' + level : '');
}
field.setCustomValidity(acceptable ? '' : message || '');
}
field.addEventListener('input', assess);
field.addEventListener('blur', assess);
// The identity check depends on these, so a password typed first and an
// email typed second is still caught.
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
for (var i = 0; i < sources.length; i++) {
sources[i].addEventListener('change', assess);
}
}
function enhanceSteps(form) { function enhanceSteps(form) {
var step1 = form.querySelector('[data-step="1"]'); var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]'); var step2 = form.querySelector('[data-step="2"]');
@@ -111,6 +214,16 @@
function sync() { function sync() {
children.hidden = !toggle.checked; children.hidden = !toggle.checked;
// Each student's name and birth year are required, but only once the
// block is in play: a `required` field inside a hidden container makes
// the form unsubmittable with no way to reach the offending control, so
// the attribute goes on and comes off with the block itself. The server
// enforces the same rule either way.
var required = children.querySelectorAll('[data-us-child-required]');
for (var r = 0; r < required.length; r++) {
required[r].required = toggle.checked;
}
if (!steps) { if (!steps) {
return; return;
} }
@@ -141,6 +254,10 @@
nextIndex += 1; nextIndex += 1;
children.insertBefore(clone, addButton.parentNode); children.insertBefore(clone, addButton.parentNode);
// The clone carries the data attribute but not necessarily the
// current required state, so settle it the same way as the rest.
sync();
}); });
} }
} }
@@ -154,6 +271,7 @@
: null; : null;
enhanceGuardian(forms[i], steps); enhanceGuardian(forms[i], steps);
enhancePassword(forms[i]);
} }
}); });
})(); })();
+35 -1
View File
@@ -77,6 +77,40 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links | | `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) | | `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
## Email and password validation
Both are checked on the server on every signup path, and the browser is given a
matching but *stricter* job so a bad password is caught before submitting.
**Email**`type="email"` and `required` in the markup, `is_email()` on the
server, then `email_exists()` for "an account already exists for this email". A
personal invite fixes the address and the server always uses the invite's own
value, so a tampered field is ignored rather than validated.
**Password**`Auth\PasswordPolicy` is the authority. It deliberately does
*not* try to reproduce a strength score in PHP; it rejects the categorically
bad, which is what a server can check without shipping a dictionary:
- shorter than `PasswordPolicy::MIN_LENGTH` (8 — NIST SP 800-63B's floor;
composition rules like "must contain a symbol" are deliberately **not** used,
as they push people towards predictable substitutions),
- one of the well-known leaked passwords,
- built from fewer than four distinct characters (`aaaaaaaa`, `abababab`),
- containing the user's own display name, email, or the part before the `@`.
The nuance happens in the browser. `register.js` scores the password with
zxcvbn through WordPress's own `password-strength-meter` script and refuses to
submit below `PasswordPolicy::MIN_SCORE` (2 of 4 — "medium"; enough to stop a
guessable password without demanding a passphrase to book a piano lesson). The
thresholds reach JavaScript via `wp_localize_script()` from the same constants
the server enforces, so the two cannot drift apart.
The verdict is applied with `setCustomValidity()` on the password field rather
than by disabling a button: the form has up to three submits plus a "Next" that
already gates on `checkValidity()`, and an invalid field stops all of them
without any needing to know why. zxcvbn's dictionary loads asynchronously, so
the gate stays open until it arrives — the server is the check that always runs.
## Registration Questions (signup step two) ## Registration Questions (signup step two)
When the studio has configured **account-scope** registration questions When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the (**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
@@ -168,7 +202,7 @@ No-op when no registration page is set.
## Parent/Guardian Signup ## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**, The registration form also offers **"I'm registering as a parent or guardian"**,
which reveals a repeatable child block (name, date of birth, and the which reveals a repeatable child block (name, birth year, and the
account-scope questions asked **per child**). Each child becomes a login-less account-scope questions asked **per child**). Each child becomes a login-less
`us_student` user linked to the guardian, and the signup policies are recorded `us_student` user linked to the guardian, and the signup policies are recorded
once per child with the guardian as the acceptor. Available on every signup path once per child with the guardian as the acceptor. Available on every signup path
+29 -4
View File
@@ -1,8 +1,8 @@
# Editor Blocks # Editor Blocks
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes, Gutenberg dynamic-block wrappers for the plugin's front-end shortcodes, so the
so the pages can be previewed and styled inside the block editor instead of pages can be previewed and styled inside the block editor instead of appearing
appearing as grey shortcode text. as grey shortcode text.
## Blocks ## Blocks
@@ -12,6 +12,8 @@ appearing as grey shortcode text.
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` | | `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` | | `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` | | `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
| `us-scheduler/family` | `[us_family]` | `Guardian\FamilyPage::render()` |
| `us-scheduler/account` | `[us_account]` | `Auth\AccountPage::render()` |
The shortcodes remain registered for back-compat; blocks and shortcodes share The shortcodes remain registered for back-compat; blocks and shortcodes share
the same page objects (constructed once in `Plugin::boot()`), so front-end the same page objects (constructed once in `Plugin::boot()`), so front-end
@@ -21,7 +23,7 @@ transform.
## Block options ## Block options
Four blocks have sidebar (inspector) options: Most blocks have sidebar (inspector) options:
| Block | Attribute | Default | Effect | | Block | Attribute | Default | Effect |
|---|---|---|---| |---|---|---|---|
@@ -34,6 +36,8 @@ Four blocks have sidebar (inspector) options:
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. | | `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. | | `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. | | `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
| `us-scheduler/family` | `loginPageId` (number) | `0` | Where visitors who are not signed in are sent to log in. Shortcode equivalent: `[us_family login_page_id="…"]`. |
| `us-scheduler/account` | `loginPageId` (number) | `0` | Where signing out returns to, and where a signed-out visitor is offered a **Sign in** link. `0` = signing out returns to the current page, and a signed-out visitor sees **nothing at all** — see below. Shortcode equivalent: `[us_account login_page_id="…"]`. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. | | `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
The page selects list all published pages; if a chosen page is later deleted, The page selects list all published pages; if a chosen page is later deleted,
@@ -105,6 +109,10 @@ placeholder content:
- **Login** — the real `templates/frontend/login-page.php` template (it has - **Login** — the real `templates/frontend/login-page.php` template (it has
no request-state dependencies). no request-state dependencies).
- **Registration** — a disabled sample of the `.us-register-form` fields. - **Registration** — a disabled sample of the `.us-register-form` fields.
- **Account** — a populated sample panel. Deliberately populated whatever the
editor user's own state: on the published page a signed-out visitor may see
nothing at all, and an empty box tells the person placing the block nothing
about where it will sit.
Each preview starts with a `.us-editor-note` paragraph explaining what the Each preview starts with a `.us-editor-note` paragraph explaining what the
published page shows instead. The note class only appears in editor previews. published page shows instead. The note class only appears in editor previews.
@@ -118,5 +126,22 @@ published page shows instead. The note class only appears in editor previews.
and fallbacks. and fallbacks.
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and - `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
fallbacks. fallbacks.
- `tests/Unit/Auth/AccountPageTest.php` — what each visitor sees, the
sign-out redirect target, and the signed-out empty render.
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS - `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
classes/ids and includes the editor note. classes/ids and includes the editor note.
## The account block's signed-out behaviour
`us-scheduler/account` is the one block that can render **nothing**. It is meant
for a header, sidebar or account page, and its whole subject is the person
signed in — which a stranger is not. A bare "you are not signed in" in a site
header is noise that cannot be acted on, so:
- **No login page chosen** → empty string for signed-out visitors.
- **Login page chosen** → a single **Sign in** link.
Signed in, it shows the display name (`Auth\UserName::format()`, so a username
is never exposed), the account email, and a **Sign out** link — deliberately
nothing else. Signing out returns to the chosen login page, or to the current page when there
is none, so a header sign-out does not also navigate the visitor somewhere.
+58 -10
View File
@@ -9,6 +9,19 @@ A guardian may also be a student in their own right — they appear in their own
"who is this for?" selector alongside their children, so a parent taking lessons "who is this for?" selector alongside their children, so a parent taking lessons
next to their kids needs only the one account. next to their kids needs only the one account.
## Vocabulary: "child" in the code, "student" in the UI
The interface says **student** and **profile**; the code says **child** and
**family**. This is deliberate, not drift. Every identifier below — the
`us_guardian_links` columns, `GuardianService::createChild()`, the `children[]`
request parameters, the `child_name` form fields, the `us-scheduler/family`
block name and the `[us_family]` shortcode — is a stable contract with the
database, saved post content and existing installs, so renaming them would break
sites for no user-visible gain. Only the strings a person reads were changed.
When adding to this feature, keep the split: internal names follow the
data model, translatable strings follow the interface.
## Core Decision: children are accountless WordPress users ## Core Decision: children are accountless WordPress users
Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`, Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`,
@@ -54,11 +67,28 @@ requires migrating every existing row.
never be linked twice. never be linked twice.
The table is a link table, not a child record: the child's **name** is their The table is a link table, not a child record: the child's **name** is their
`display_name` on `wp_users`, and their date of birth is the `us_date_of_birth` `display_name` on `wp_users`, and their birth year is the `us_birth_year`
user meta. Keeping them on the user row means the admin student screens, user meta. Keeping them on the user row means the admin student screens,
`get_users()` ordering, and every existing `student_id` lookup keep working with `get_users()` ordering, and every existing `student_id` lookup keep working with
no special-casing. no special-casing.
### The legacy `us_date_of_birth` meta
This feature originally collected a full date of birth in `us_date_of_birth`.
Nothing writes that key any more. It is handled entirely inside
`GuardianService`:
- **Read**`birthYear()` falls back to the year of the old date when
`us_birth_year` is absent, so a child added before the change still shows one
without a migration step.
- **Write**`setBirthYear()` deletes `us_date_of_birth` on *every* save,
including a save that clears the year. Without that the fallback would
resurrect the old date on the next read and the year could never be cleared.
The upshot is a lazy migration: a child's full date survives until their record
is next edited, then goes for good. There is no bulk purge — a site that wants
the remaining old dates gone should delete the `us_date_of_birth` meta directly.
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()` v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
refuses to link a child that already has a guardian. The unique key and the refuses to link a child that already has a guardian. The unique key and the
guardian-side lookups already support many-to-many, so adding a second guardian guardian-side lookups already support many-to-many, so adding a second guardian
@@ -117,18 +147,36 @@ least one child name.
Per child the form collects: Per child the form collects:
- **Name** (required) - **Name** (required)
- **Date of birth** (optional, `us_date_of_birth` meta) - **Birth year** (required, `us_birth_year` meta) — a four-digit year between
1900 and the current year. `GuardianService::normaliseBirthYear()` is the one
definition of what counts, shared by the signup form's up-front validation and
by `createChild()`/`updateChild()` themselves, so a bad year is refused rather
than quietly discarded and a typo cannot leave a nonsense age on the record.
- **Every account-scope registration question** (`Registration\Question`, - **Every account-scope registration question** (`Registration\Question`,
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in `SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
practice they describe the student (instrument, level, school). The guardian practice they describe the student (instrument, level, school). The guardian
answers them on the child's behalf; the answer row's `student_id` is the child. answers them on the child's behalf; the answer row's `student_id` is the child.
Name and birth year are marked required in the labels the same way a required
question is, but the signup form **cannot** lean on the browser to enforce them:
the child blocks are hidden until the parent/guardian box is ticked, and a
`required` field inside a hidden container makes the form unsubmittable with no
control the user can reach to fix. `register.js` therefore puts `required` on
and takes it off along with the block itself (`[data-us-child-required]`), and
the server checks regardless — which is what makes the rule hold with
JavaScript off. The profile screen has no such problem: its forms are always
visible, so the attribute is static there.
Order of operations in `RegistrationPage::handleSubmit()`: Order of operations in `RegistrationPage::handleSubmit()`:
1. Validate the guardian's own fields (email, password, policies). 1. Validate the guardian's own fields (email, password, policies).
2. Validate **every** child block — a missing child name or a missing required 2. Validate **every** child block — a missing name, a missing or unusable birth
per-child answer fails the whole submission **before** any user is created, so year, or a missing required per-child answer fails the whole submission
a half-registered family is never left behind. **before** any user is created, so a half-registered family is never left
behind. An **entirely empty** block is dropped instead, because the form
always renders one spare for "add another"; a block with anything at all
typed into it is kept and reported on, rather than silently discarding what
the guardian entered.
3. Create the guardian user. 3. Create the guardian user.
4. For each child: create the accountless user, link it, record its answers, and 4. For each child: create the accountless user, link it, record its answers, and
record the signup policy acceptances **against the child** with record the signup policy acceptances **against the child** with
@@ -159,13 +207,13 @@ child.
## Managing children ## Managing children
`[us_family]` (block: **Family**) renders the guardian's manage-children screen: `[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
list the children, add one, edit a name/date of birth, remove one. list the children, add one, edit a name/birth year, remove one.
- **Add** creates another accountless child user and links it. Account-scope - **Add** creates another accountless child user and links it. Account-scope
questions are asked here too, so a child added later carries the same questions are asked here too, so a child added later carries the same
information as one added at signup. information as one added at signup.
- **Edit** updates `display_name` and `us_date_of_birth`. - **Edit** updates `display_name` and `us_birth_year`.
- **Remove** unlinks the child and **deletes the child user**, but only when the - **Remove** unlinks the child and **deletes the child user**, but only when the
child has no lessons and no enrolments — a child with history is refused, so child has no lessons and no enrolments — a child with history is refused, so
removing one can never orphan a lesson, payment or credit removing one can never orphan a lesson, payment or credit
@@ -219,11 +267,11 @@ than being left as a single-student-only path.
## Admin ## Admin
- **Students list** gains a **Guardian / Children** column: a child links to its - **Students list** gains a **Profile** column: a child links to its
guardian's detail screen, a guardian lists its children as links. Children are guardian's detail screen, a guardian lists its children as links. Children are
listed alongside every other student rather than nested, so nothing about listed alongside every other student rather than nested, so nothing about
finding a student changes. finding a student changes.
- **Student detail** gains a **Family** panel — the guardian (for a child) or - **Student detail** gains a **Profile** panel — the guardian (for a child) or
the children (for a guardian), each a link to the other's screen — and the the children (for a guardian), each a link to the other's screen — and the
credit balance shown is the **payer's** balance, labelled with whose it is, so credit balance shown is the **payer's** balance, labelled with whose it is, so
an admin looking at a child sees the family balance that will actually settle an admin looking at a child sees the family balance that will actually settle
+2 -2
View File
@@ -88,8 +88,8 @@ All actions are nonce-protected POSTs handled on the detail page:
`tests/Unit/Payment/PaymentRepositoryTest.php` `tests/Unit/Payment/PaymentRepositoryTest.php`
## Family Relationships ## Family Relationships
The students list gains a **Family** column — a child links to their guardian, The students list gains a **Profile** column — a child links to their guardian,
a guardian lists their children — and the student screen a **Family** panel. A a guardian lists their children — and the student screen a **Profile** panel. A
child's listed email is their guardian's, since a child's own address is an child's listed email is their guardian's, since a child's own address is an
undeliverable placeholder, and the credit balance shown is the payer's, labelled undeliverable placeholder, and the credit balance shown is the payer's, labelled
with whose account holds it. See `parent-guardian-accounts.md`. with whose account holds it. See `parent-guardian-accounts.md`.
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Who is signed in, and the way out.
*
* Meant for a header, sidebar or account page somewhere it sits alongside
* other content rather than being the whole of it. That shapes the two
* decisions below.
*/
class AccountPage {
/**
* Renders the account shortcode/block output.
*
* Signed out, this renders a sign-in link when a login page is configured and
* **nothing at all** when one is not. A block whose whole job is "you are
* signed in as X" has nothing to say to a stranger, and a bare "you are not
* signed in" in a site header is noise with no way to act on it. The editor
* preview shows the populated state regardless, so the block is never
* invisible to the person placing it.
*
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
*/
public function render( array $atts ): string {
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
$loginUrl = $this->pageUrl( $loginPageId );
wp_enqueue_style( 'us-scheduler' );
if ( ! is_user_logged_in() ) {
if ( null === $loginUrl ) {
return '';
}
return sprintf(
'<div class="us-account us-account-out"><a class="us-account-signin" href="%s">%s</a></div>',
esc_url( $loginUrl ),
esc_html__( 'Sign in', 'unsupervised-schedular' )
);
}
// Always a WP_User here — is_user_logged_in() above rules out the
// id-0 placeholder wp_get_current_user() returns for a visitor.
$user = wp_get_current_user();
$name = UserName::format( $user, get_current_user_id() );
$email = $user->user_email;
// Back to where they were, so signing out of a header link does not also
// navigate them somewhere. The login page is the better landing spot when
// one is configured, since the current page may be members-only.
$logoutUrl = wp_logout_url( $loginUrl ?? (string) get_permalink() );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/account-page.php';
return (string) ob_get_clean();
}
/**
* Permalink of a configured page, or null when none is chosen or the chosen
* page has since been deleted.
*/
private function pageUrl( int $pageId ): ?string {
if ( $pageId <= 0 ) {
return null;
}
$url = get_permalink( $pageId );
return is_string( $url ) ? $url : null;
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* What counts as an acceptable signup password.
*
* The check is deliberately split across the two sides, because the two sides
* can do different things:
*
* - **The browser** runs zxcvbn (WordPress ships it as `password-strength-meter`)
* and gates the submit button on {@see MIN_SCORE}. That is the nuanced test
* it knows that `Tr0ub4dor&3` is weaker than `correct horse battery staple`
* but it is only advice, because anything in a browser can be turned off.
* - **This class** runs on the server and is the rule that actually holds. It
* cannot score a password the way zxcvbn does without shipping a dictionary,
* so it does not pretend to: it rejects the categorically bad too short,
* the user's own name or email, a password from the well-known lists, or one
* built from almost no distinct characters.
*
* Neither half is sufficient alone, which is the point. A password that clears
* both is not guaranteed strong; one that fails either is definitely not.
*/
class PasswordPolicy {
/**
* Minimum length. NIST SP 800-63B puts the floor at 8 and explicitly advises
* against composition rules ("must contain a symbol") on the grounds that they
* push people towards predictable substitutions. Length plus the checks below
* does more for less annoyance.
*/
public const MIN_LENGTH = 8;
/**
* The zxcvbn score the browser demands before it will let the form submit,
* on WordPress's 0-4 scale: 0-1 weak, 2 medium, 3-4 strong. Two rejects the
* passwords a stranger would guess while still accepting an ordinary
* memorable one a studio signup form is not a bank.
*/
public const MIN_SCORE = 2;
/**
* How much of the user's own identity has to appear in the password before it
* is refused. Short enough to catch a name inside a longer password, long
* enough that a two- or three-letter coincidence does not trip it.
*/
private const IDENTITY_FRAGMENT_LENGTH = 4;
/** Fewest distinct characters a password may be built from. */
private const MIN_DISTINCT_CHARACTERS = 4;
/**
* Why this password is unacceptable, or null when it passes.
*
* `$email` and `$displayName` are what the same submission is claiming as an
* identity, so they can be checked against the password before either exists
* as a user.
*/
public static function validate( string $password, string $email = '', string $displayName = '' ): ?string {
// Not trimmed: a leading or trailing space is a legitimate character, and
// silently changing what someone typed would lock them out later.
if ( strlen( $password ) < self::MIN_LENGTH ) {
return sprintf(
/* translators: %d: minimum number of characters. */
__( 'Please choose a password of at least %d characters.', 'unsupervised-schedular' ),
self::MIN_LENGTH
);
}
$lower = strtolower( $password );
if ( in_array( $lower, self::commonPasswords(), true ) ) {
return __( 'That password is one of the most commonly used ones. Please choose something less guessable.', 'unsupervised-schedular' );
}
if ( count( array_unique( str_split( $lower ) ) ) < self::MIN_DISTINCT_CHARACTERS ) {
return __( 'Please choose a password built from more than a few repeated characters.', 'unsupervised-schedular' );
}
if ( self::echoesIdentity( $lower, $email, $displayName ) ) {
return __( 'Please choose a password that does not contain your name or email address.', 'unsupervised-schedular' );
}
return null;
}
/**
* Whether the password contains the user's display name, their email address,
* or the part of it before the `@` the first things anyone guessing would
* try, and the reason "grace2019" is worse than its length suggests.
*/
private static function echoesIdentity( string $lowerPassword, string $email, string $displayName ): bool {
$email = strtolower( trim( $email ) );
$localPart = '' !== $email ? (string) strstr( $email . '@', '@', true ) : '';
$fragments = [ $email, $localPart, strtolower( trim( $displayName ) ) ];
foreach ( $fragments as $fragment ) {
if ( strlen( $fragment ) >= self::IDENTITY_FRAGMENT_LENGTH && str_contains( $lowerPassword, $fragment ) ) {
return true;
}
}
return false;
}
/**
* Passwords common enough that a guess costs nothing. Only entries at least
* {@see MIN_LENGTH} long are worth listing anything shorter is already
* refused so this is the long tail of the usual leaked-password lists
* rather than the whole of it. zxcvbn in the browser covers the rest.
*
* @return list<string>
*/
private static function commonPasswords(): array {
return [
'password',
'password1',
'password12',
'password123',
'passw0rd',
'p@ssword',
'p@ssw0rd',
'12345678',
'123456789',
'1234567890',
'123123123',
'qwertyui',
'qwertyuiop',
'qwerty123',
'qwerty12',
'1qaz2wsx',
'zaq12wsx',
'iloveyou',
'princess',
'sunshine',
'football',
'baseball',
'basketball',
'superman',
'batman123',
'trustno1',
'welcome1',
'welcome123',
'letmein1',
'letmein123',
'admin123',
'administrator',
'abc12345',
'abcd1234',
'monkey123',
'dragon123',
'michael1',
'jennifer',
'starwars',
'computer',
'whatever',
'freedom1',
'changeme',
'secret123',
'login123',
];
}
}
+66 -19
View File
@@ -124,6 +124,28 @@ class RegistrationPage {
// needed whenever the form itself is on screen. // needed whenever the form itself is on screen.
if ( $canRegister && '' === $successType ) { if ( $canRegister && '' === $successType ) {
wp_enqueue_script( 'us-scheduler-register' ); wp_enqueue_script( 'us-scheduler-register' );
// The browser gate reads the same numbers the server enforces, so the
// two cannot drift into disagreeing about what it accepted.
wp_localize_script(
'us-scheduler-register',
'usSchedulerPassword',
[
'minLength' => PasswordPolicy::MIN_LENGTH,
'minScore' => PasswordPolicy::MIN_SCORE,
'strings' => [
'short' => sprintf(
/* translators: %d: minimum number of characters. */
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
PasswordPolicy::MIN_LENGTH
),
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
],
]
);
} }
ob_start(); ob_start();
@@ -248,10 +270,6 @@ class RegistrationPage {
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) ); $password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) ); $displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
if ( strlen( $password ) < 8 ) {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
}
// The email is fixed by a personal invite; group-link signups and // The email is fixed by a personal invite; group-link signups and
// self-signups supply their own. // self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) { if ( $inviteValid && ! $invite->isGroup() ) {
@@ -263,6 +281,15 @@ class RegistrationPage {
} }
} }
// After the email, so the password can be checked against it. The browser
// scores the password with zxcvbn and refuses to submit a weak one, but
// that is advice a client can decline to take — this is the check that
// holds. See PasswordPolicy for why the two halves differ.
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
if ( null !== $passwordError ) {
return esc_html( $passwordError );
}
$policyForms = $this->signupPolicies(); $policyForms = $this->signupPolicies();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) ); $accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
@@ -286,14 +313,28 @@ class RegistrationPage {
// Everything is validated before a single user is created, so a bad child // Everything is validated before a single user is created, so a bad child
// block never leaves a half-registered family behind. // block never leaves a half-registered family behind.
if ( $isGuardian && [] === $children ) { if ( $isGuardian && [] === $children ) {
return esc_html__( 'Please add at least one child, or uncheck the parent/guardian option.', 'unsupervised-schedular' ); return esc_html__( 'Please add at least one student, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
}
// Name and birth year are required per student, and are checked here for
// the same reason the questions below are: the child blocks are hidden
// until the guardian box is ticked, so the browser cannot be asked to
// enforce them without blocking a signup that has no children at all.
foreach ( $children as $child ) {
if ( '' === $child['name'] ) {
return esc_html__( 'Please give each student a name.', 'unsupervised-schedular' );
}
if ( 0 === GuardianService::normaliseBirthYear( $child['birth_year'] ) ) {
return esc_html( GuardianService::birthYearError() );
}
} }
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) { foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
foreach ( $accountQuestions as $question ) { foreach ( $accountQuestions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) { if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
return $isGuardian return $isGuardian
? esc_html__( 'Please answer all required registration questions for each child.', 'unsupervised-schedular' ) ? esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' )
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' ); : esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
} }
} }
@@ -477,11 +518,15 @@ class RegistrationPage {
/** /**
* The child blocks submitted with a guardian signup, as * The child blocks submitted with a guardian signup, as
* `children[<n>][name|dob|answers]`. Blocks with no name are dropped rather * `children[<n>][name|birth_year|answers]`.
* than rejected the form always renders one spare block for "add another",
* and an untouched spare is not a mistake the guardian needs telling about.
* *
* @return list<array{name: string, dob: string, answers: array<int, string>}> * An **entirely empty** block is dropped rather than rejected the form always
* renders one spare for "add another", and an untouched spare is not a mistake
* the guardian needs telling about. A block with anything at all filled in is
* kept, so {@see handleSubmit()} can reject it for the missing name or birth
* year rather than silently discarding what they typed.
*
* @return list<array{name: string, birth_year: string, answers: array<int, string>}>
*/ */
private function submittedChildren(): array { private function submittedChildren(): array {
// The submit nonce is verified by the caller before this runs. // The submit nonce is verified by the caller before this runs.
@@ -497,20 +542,22 @@ class RegistrationPage {
continue; continue;
} }
$name = sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ); $name = trim( sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ) );
if ( '' === trim( $name ) ) { $birthYear = trim( sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ) );
continue;
}
$answers = []; $answers = [];
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) { foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); $answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
} }
if ( '' === $name && '' === $birthYear && '' === trim( implode( '', $answers ) ) ) {
continue;
}
$out[] = [ $out[] = [
'name' => $name, 'name' => $name,
'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ), 'birth_year' => $birthYear,
'answers' => $answers, 'answers' => $answers,
]; ];
} }
@@ -528,7 +575,7 @@ class RegistrationPage {
* re-register and children they never confirmed, so it is undone entirely and * re-register and children they never confirmed, so it is undone entirely and
* they simply try again. * they simply try again.
* *
* @param list<array{name: string, dob: string, answers: array<int, string>}> $children * @param list<array{name: string, birth_year: string, answers: array<int, string>}> $children
* @param list<Question> $questions * @param list<Question> $questions
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms * @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
*/ */
@@ -536,7 +583,7 @@ class RegistrationPage {
$created = []; $created = [];
foreach ( $children as $child ) { foreach ( $children as $child ) {
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['dob'] ); $childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
if ( $childId instanceof \WP_Error ) { if ( $childId instanceof \WP_Error ) {
foreach ( $created as $id ) { foreach ( $created as $id ) {
+31 -7
View File
@@ -15,6 +15,12 @@ namespace Unsupervised\Schedular;
*/ */
class BlockPreview { class BlockPreview {
/**
* The marker a required field's label carries, matching the one
* {@see Registration\QuestionField::render()} puts on a required question.
*/
private const REQUIRED_MARK = ' <span class="us-required" aria-hidden="true">*</span>';
/** /**
* Sample booking page. * Sample booking page.
* *
@@ -186,24 +192,42 @@ class BlockPreview {
} }
$add = sprintf( $add = sprintf(
'<h4>%s</h4><p><label for="us-child-name">%s</label><input type="text" id="us-child-name"></p>' '<h4>%s</h4><p><label for="us-child-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-child-name"></p>'
. '<p><label for="us-child-dob">%s</label><input type="date" id="us-child-dob"></p>' . '<p><label for="us-child-birth-year">%s' . self::REQUIRED_MARK . '</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>'
. '<p><button type="button" disabled>%s</button></p>', . '<p><button type="button" disabled>%s</button></p>',
esc_html__( 'Add a child', 'unsupervised-schedular' ), esc_html__( 'Add a student', 'unsupervised-schedular' ),
esc_html__( 'Name', 'unsupervised-schedular' ), esc_html__( 'Name', 'unsupervised-schedular' ),
esc_html__( 'Date of birth', 'unsupervised-schedular' ), esc_html__( 'Birth year', 'unsupervised-schedular' ),
esc_html__( 'Add child', 'unsupervised-schedular' ) esc_html__( 'Add student', 'unsupervised-schedular' )
); );
return sprintf( return sprintf(
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>', '<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
self::note( __( 'Editor preview — signed-in guardians see and manage their own children here.', 'unsupervised-schedular' ) ), self::note( __( 'Editor preview — signed-in guardians see and manage their own students here.', 'unsupervised-schedular' ) ),
esc_html__( 'Your family', 'unsupervised-schedular' ), esc_html__( 'Your profile', 'unsupervised-schedular' ),
$children, $children,
$add $add
); );
} }
/**
* Sample account panel. Shown populated whatever the editor's own login
* state, since on the published page a signed-out visitor may see nothing at
* all and an empty box tells the person placing the block nothing.
*/
public static function account(): string {
return sprintf(
'<div class="us-account">%s'
. '<p class="us-account-who"><span class="us-account-name">%s</span>'
. '<span class="us-account-email">%s</span></p>'
. '<p class="us-account-actions"><a class="us-account-signout" href="#">%s</a></p></div>',
self::note( __( 'Editor preview — each visitor sees their own account here.', 'unsupervised-schedular' ) ),
esc_html__( 'Grace Hopper', 'unsupervised-schedular' ),
esc_html__( '[email protected]', 'unsupervised-schedular' ),
esc_html__( 'Sign out', 'unsupervised-schedular' )
);
}
private static function note( string $text ): string { private static function note( string $text ): string {
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>'; return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
} }
+20
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular; namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Booking\BookingPage;
@@ -30,6 +31,7 @@ class BlockRegistrar {
private RegistrationPage $registrationPage, private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage, private GroupClassPage $groupClassPage,
private FamilyPage $familyPage, private FamilyPage $familyPage,
private AccountPage $accountPage,
) {} ) {}
public function register(): void { public function register(): void {
@@ -148,6 +150,15 @@ class BlockRegistrar {
], ],
], ],
], ],
'us-scheduler/account' => [
'render' => [ $this, 'renderAccount' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
],
],
]; ];
} }
@@ -195,6 +206,15 @@ class BlockRegistrar {
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 ); return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
} }
/**
* Renders the account (who is signed in) block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderAccount( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes );
}
/** /**
* Renders the family (manage-children) block. * Renders the family (manage-children) block.
* *
+1 -1
View File
@@ -35,7 +35,7 @@ class ChildLoginGate {
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) { if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
return new \WP_Error( return new \WP_Error(
'us_child_account', 'us_child_account',
esc_html__( 'This is a child account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' ) esc_html__( 'This is a managed student account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
); );
} }
+7 -7
View File
@@ -50,7 +50,7 @@ class FamilyPage {
'<p>%s <a href="%s">%s</a>.</p>', '<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ), esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( $this->loginUrl( $loginPageId ) ), esc_url( $this->loginUrl( $loginPageId ) ),
esc_html__( 'log in to manage your family', 'unsupervised-schedular' ) esc_html__( 'log in to manage your profile', 'unsupervised-schedular' )
); );
} }
@@ -119,7 +119,7 @@ class FamilyPage {
*/ */
private function handleAdd( int $guardianId ): string|\WP_Error { private function handleAdd( int $guardianId ): string|\WP_Error {
$name = $this->postString( 'child_name' ); $name = $this->postString( 'child_name' );
$dateOfBirth = $this->postString( 'child_dob' ); $birthYear = $this->postString( 'child_birth_year' );
$relationship = $this->postString( 'child_relationship' ); $relationship = $this->postString( 'child_relationship' );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
@@ -130,7 +130,7 @@ class FamilyPage {
return $missing; return $missing;
} }
$childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship ); $childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
if ( $childId instanceof \WP_Error ) { if ( $childId instanceof \WP_Error ) {
return $childId; return $childId;
} }
@@ -144,7 +144,7 @@ class FamilyPage {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) ); $error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_birth_year' ) );
return $error ?? self::RESULT_UPDATED; return $error ?? self::RESULT_UPDATED;
} }
@@ -168,7 +168,7 @@ class FamilyPage {
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error { private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
foreach ( $questions as $question ) { foreach ( $questions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) { if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this child.', 'unsupervised-schedular' ) ); return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this student.', 'unsupervised-schedular' ) );
} }
} }
@@ -237,9 +237,9 @@ class FamilyPage {
*/ */
private function noticeFor( string $result ): string { private function noticeFor( string $result ): string {
return match ( $result ) { return match ( $result ) {
self::RESULT_ADDED => __( 'Child added.', 'unsupervised-schedular' ), self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ), self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
self::RESULT_REMOVED => __( 'Child removed.', 'unsupervised-schedular' ), self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
default => '', default => '',
}; };
} }
+109 -30
View File
@@ -23,9 +23,24 @@ class GuardianService {
*/ */
public const META_CHILD = 'us_child'; public const META_CHILD = 'us_child';
/** A child's date of birth (`Y-m-d`), collected at signup and editable after. */ /** A child's birth year (`YYYY`), collected at signup and editable after. */
public const META_BIRTH_YEAR = 'us_birth_year';
/**
* The full date of birth this feature used to collect. Nothing writes it any
* more: it is read once, to derive a birth year for a child who predates the
* change, and cleared the moment that child's record is next saved. Kept
* public so a site that wants to purge the old dates outright can find them.
*/
public const META_DOB = 'us_date_of_birth'; public const META_DOB = 'us_date_of_birth';
/**
* The earliest birth year the form will accept. Old enough for any student a
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
* that would otherwise be stored as a plausible-looking year.
*/
private const MIN_BIRTH_YEAR = 1900;
/** /**
* Domain used for a child's placeholder login address. `.invalid` is reserved * Domain used for a child's placeholder login address. `.invalid` is reserved
* by RFC 2606 and can never resolve, so a child's address is guaranteed * by RFC 2606 and can never resolve, so a child's address is guaranteed
@@ -45,13 +60,17 @@ class GuardianService {
* is random and discarded it is never stored anywhere readable, emailed, or * is random and discarded it is never stored anywhere readable, emailed, or
* shown so the account cannot be signed into even if the gate were removed. * shown so the account cannot be signed into even if the gate were removed.
* *
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress * Returns the new user ID, or a `WP_Error` when the name is blank, the birth
* refuses the insert. * year is missing or unusable, or WordPress refuses the insert.
*/ */
public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error { public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
$name = trim( $name ); $name = trim( $name );
if ( '' === $name ) { if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) ); return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
}
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
} }
$email = $this->childEmail(); $email = $this->childEmail();
@@ -73,7 +92,7 @@ class GuardianService {
$userId = (int) $userId; $userId = (int) $userId;
update_user_meta( $userId, self::META_CHILD, '1' ); update_user_meta( $userId, self::META_CHILD, '1' );
$this->setDateOfBirth( $userId, $dateOfBirth ); $this->setBirthYear( $userId, $birthYear );
$linkId = $this->guardians->insert( $linkId = $this->guardians->insert(
new GuardianLink( new GuardianLink(
@@ -89,27 +108,31 @@ class GuardianService {
if ( $linkId <= 0 ) { if ( $linkId <= 0 ) {
$this->deleteUser( $userId ); $this->deleteUser( $userId );
return new \WP_Error( 'link_failed', __( 'Could not add this child. Please contact the studio.', 'unsupervised-schedular' ) ); return new \WP_Error( 'link_failed', __( 'Could not add this student. Please contact the studio.', 'unsupervised-schedular' ) );
} }
return $userId; return $userId;
} }
/** /**
* Rename a child and update their date of birth. Refuses a student the caller * Rename a child and update their birth year. Refuses a student the caller
* is not the guardian of, so the family screen cannot be turned into an * is not the guardian of, so the family screen cannot be turned into an
* arbitrary user editor by posting someone else's id. * arbitrary user editor by posting someone else's id.
* *
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}. * Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
*/ */
public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): ?\WP_Error { public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
} }
$name = trim( $name ); $name = trim( $name );
if ( '' === $name ) { if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) ); return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
}
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
} }
$result = wp_update_user( $result = wp_update_user(
@@ -124,7 +147,7 @@ class GuardianService {
return $result; return $result;
} }
$this->setDateOfBirth( $studentId, $dateOfBirth ); $this->setBirthYear( $studentId, $birthYear );
return null; return null;
} }
@@ -139,13 +162,13 @@ class GuardianService {
*/ */
public function removeChild( int $guardianId, int $studentId ): ?\WP_Error { public function removeChild( int $guardianId, int $studentId ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
} }
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) { if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
return new \WP_Error( return new \WP_Error(
'has_history', 'has_history',
__( 'This child has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' ) __( 'This student has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' )
); );
} }
@@ -235,7 +258,7 @@ class GuardianService {
* A guardian's children, in link order, with the details the family and admin * A guardian's children, in link order, with the details the family and admin
* screens display. * screens display.
* *
* @return list<array{id: int, name: string, date_of_birth: string, relationship: string}> * @return list<array{id: int, name: string, birth_year: string, relationship: string}>
*/ */
public function children( int $guardianId ): array { public function children( int $guardianId ): array {
$out = []; $out = [];
@@ -244,10 +267,10 @@ class GuardianService {
$user = get_userdata( $link->studentId ); $user = get_userdata( $link->studentId );
$out[] = [ $out[] = [
'id' => $link->studentId, 'id' => $link->studentId,
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ), 'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ), 'birth_year' => $this->birthYear( $link->studentId ),
'relationship' => $link->relationship, 'relationship' => $link->relationship,
]; ];
} }
@@ -327,24 +350,80 @@ class GuardianService {
} }
/** /**
* Store a child's date of birth, or clear it when blank or unparseable. Kept * Store a child's birth year, or clear it when blank or out of range.
* as `Y-m-d` so it sorts and displays consistently wherever it is read. *
* Either way the legacy full date of birth goes with it. That is what makes
* the read fallback in {@see birthYear()} safe: without it, clearing the year
* on a child who predates this change would leave the old date behind for the
* fallback to resurrect on the very next read.
*/ */
private function setDateOfBirth( int $userId, string $dateOfBirth ): void { private function setBirthYear( int $userId, string $birthYear ): void {
$dateOfBirth = trim( $dateOfBirth ); delete_user_meta( $userId, self::META_DOB );
if ( '' === $dateOfBirth ) { $year = self::normaliseBirthYear( $birthYear );
delete_user_meta( $userId, self::META_DOB );
if ( 0 === $year ) {
delete_user_meta( $userId, self::META_BIRTH_YEAR );
return; return;
} }
$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth ); update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
if ( false === $parsed ) { }
delete_user_meta( $userId, self::META_DOB );
return; /**
* A submitted birth year as an integer, or 0 when it is blank, not a number,
* or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
* every time, so it is refused rather than stored.
*
* Public and static so the signup form can reject a bad year up front, before
* it creates any users, without a second copy of the rule to keep in step.
*/
public static function normaliseBirthYear( string $birthYear ): int {
$birthYear = trim( $birthYear );
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
return 0;
} }
update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) ); $year = (int) $birthYear;
if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
return 0;
}
return $year;
}
/**
* The message shown when a birth year is missing or unusable. One phrasing,
* shared by the signup form and the profile screen, so a guardian is told the
* same thing whichever way they got there.
*/
public static function birthYearError(): string {
return sprintf(
/* translators: %d: the earliest birth year the form accepts. */
__( 'Please give each student a birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
self::MIN_BIRTH_YEAR
);
}
/**
* A child's birth year, or an empty string when none is recorded.
*
* Falls back to the year of the full date of birth this feature used to
* collect, so a child added before the change still shows one. The fallback
* is read-only and one-way: {@see setBirthYear()} drops the old date as soon
* as the record is saved again.
*/
private function birthYear( int $userId ): string {
$year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
if ( '' !== $year ) {
return $year;
}
$legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
} }
/** /**
+4 -2
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler; use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationLoginGate; use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationMailer;
@@ -99,6 +100,7 @@ class Plugin {
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians ); $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
$groupClassPage = new GroupClassPage( $guardians ); $groupClassPage = new GroupClassPage( $guardians );
$familyPage = new FamilyPage( $guardians, $questions, $answers ); $familyPage = new FamilyPage( $guardians, $questions, $answers );
$accountPage = new AccountPage();
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register(); ( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
@@ -110,7 +112,7 @@ class Plugin {
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
} }
} }
+19 -2
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular; namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Booking\BookingPage;
@@ -18,6 +19,7 @@ class ShortcodeRegistrar {
private RegistrationPage $registrationPage, private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage, private GroupClassPage $groupClassPage,
private FamilyPage $familyPage, private FamilyPage $familyPage,
private AccountPage $accountPage,
) {} ) {}
public function register(): void { public function register(): void {
@@ -26,6 +28,7 @@ class ShortcodeRegistrar {
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) ); add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) ); add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) ); add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
add_shortcode( 'us_account', self::shortcode( [ $this->accountPage, 'render' ] ) );
// Process registration submissions before output so the invite branch's // Process registration submissions before output so the invite branch's
// auth cookie is actually sent (render() runs too late, during the_content). // auth cookie is actually sent (render() runs too late, during the_content).
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] ); add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
@@ -88,7 +91,21 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true ); wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true ); wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
// Progressive enhancement for the two-step registration form (no dependencies). /*
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true ); * Progressive enhancement for the two-step registration form.
*
* `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
* the signup form scores a password exactly the way wp-admin does rather
* than inventing a second opinion. It pulls in `zxcvbn-async`, which
* fetches the (large) dictionary only once the page has loaded hence
* the guard in register.js for the window where it is not there yet.
*/
wp_register_script(
'us-scheduler-register',
USC_PLUGIN_URL . 'assets/js/register.js',
[ 'password-strength-meter' ],
USC_VERSION,
true
);
} }
} }
+6 -6
View File
@@ -18,7 +18,7 @@ if (! defined('ABSPATH')) {
* @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child. * @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child.
* @var string $creditCurrency * @var string $creditCurrency
* @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves. * @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves.
* @var list<array{id: int, name: string, date_of_birth: string, relationship: string}> $children Children this student books for. * @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children Children this student books for.
* @var array{id: int, name: string, email: string} $payer Who is billed for this student themselves, or their guardian. * @var array{id: int, name: string, email: string} $payer Who is billed for this student themselves, or their guardian.
* @var string $pageSlug * @var string $pageSlug
* @var string $backUrl * @var string $backUrl
@@ -110,7 +110,7 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
</form> </form>
<?php if ($guardian !== null || ! empty($children)) : ?> <?php if ($guardian !== null || ! empty($children)) : ?>
<h2><?php esc_html_e('Family', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Profile', 'unsupervised-schedular'); ?></h2>
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?> <?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
<?php if ($guardian !== null) : ?> <?php if ($guardian !== null) : ?>
<p> <p>
@@ -123,7 +123,7 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
); );
?> ?>
</p> </p>
<p class="description"><?php esc_html_e('This is a child account: it has no login of its own, and its email address is a placeholder that cannot receive mail.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('This is a managed student account: it has no login of its own, and its email address is a placeholder that cannot receive mail.', 'unsupervised-schedular'); ?></p>
<?php endif; ?> <?php endif; ?>
<?php if (! empty($children)) : ?> <?php if (! empty($children)) : ?>
<p><?php esc_html_e('Books and pays for:', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('Books and pays for:', 'unsupervised-schedular'); ?></p>
@@ -131,8 +131,8 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<?php foreach ($children as $child) : ?> <?php foreach ($children as $child) : ?>
<li> <li>
<a href="<?php echo esc_url($detailUrl($child['id'])); ?>"><?php echo esc_html($child['name']); ?></a> <a href="<?php echo esc_url($detailUrl($child['id'])); ?>"><?php echo esc_html($child['name']); ?></a>
<?php if ($child['date_of_birth'] !== '') : ?> <?php if ($child['birth_year'] !== '') : ?>
<span class="description"><?php echo esc_html($child['date_of_birth']); ?></span> <span class="description"><?php echo esc_html($child['birth_year']); ?></span>
<?php endif; ?> <?php endif; ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
@@ -293,7 +293,7 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<?php <?php
printf( printf(
/* translators: %s: name of the parent/guardian whose account holds the balance. */ /* translators: %s: name of the parent/guardian whose account holds the balance. */
esc_html__('Held on %ss account — the family shares one balance.', 'unsupervised-schedular'), esc_html__('Held on %ss account — the profile shares one balance.', 'unsupervised-schedular'),
esc_html($payer['name']) esc_html($payer['name'])
); );
?> ?>
+3 -3
View File
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
} }
/** /**
* @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int, guardian: array{id: int, name: string, email: string}|null, children: list<array{id: int, name: string, date_of_birth: string, relationship: string}>}> $students * @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int, guardian: array{id: int, name: string, email: string}|null, children: list<array{id: int, name: string, birth_year: string, relationship: string}>}> $students
* @var string $pageSlug * @var string $pageSlug
*/ */
@@ -25,7 +25,7 @@ $familyCell = static function (array $student) use ($pageSlug): string {
if ($student['guardian'] !== null) { if ($student['guardian'] !== null) {
return sprintf( return sprintf(
/* translators: %s: linked name of the parent/guardian who books for this student. */ /* translators: %s: linked name of the parent/guardian who books for this student. */
esc_html__('Child of %s', 'unsupervised-schedular'), esc_html__('Managed by %s', 'unsupervised-schedular'),
$link($student['guardian']['id'], $student['guardian']['name']) $link($student['guardian']['id'], $student['guardian']['name'])
); );
} }
@@ -51,7 +51,7 @@ $familyCell = static function (array $student) use ($pageSlug): string {
<tr> <tr>
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Family', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Profile', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th>
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var string $name Display name of the signed-in visitor.
* @var string $email Their account email.
* @var string $logoutUrl Nonced sign-out URL, already carrying its redirect.
*/
?>
<div class="us-account">
<p class="us-account-who">
<span class="us-account-name"><?php echo esc_html($name); ?></span>
<?php if ($email !== '') : ?>
<span class="us-account-email"><?php echo esc_html($email); ?></span>
<?php endif; ?>
</p>
<p class="us-account-actions">
<a class="us-account-signout" href="<?php echo esc_url($logoutUrl); ?>"><?php esc_html_e('Sign out', 'unsupervised-schedular'); ?></a>
</p>
</div>
+14 -14
View File
@@ -8,7 +8,7 @@ if (! defined('ABSPATH')) {
} }
/** /**
* @var list<array{id: int, name: string, date_of_birth: string, relationship: string}> $children * @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child. * @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
* @var string $error Validation error from the last submission, if any. * @var string $error Validation error from the last submission, if any.
* @var string $notice Confirmation of a completed add/edit/remove, if any. * @var string $notice Confirmation of a completed add/edit/remove, if any.
@@ -16,7 +16,7 @@ if (! defined('ABSPATH')) {
*/ */
?> ?>
<div class="us-family"> <div class="us-family">
<h3><?php esc_html_e('Your family', 'unsupervised-schedular'); ?></h3> <h3><?php esc_html_e('Your profile', 'unsupervised-schedular'); ?></h3>
<?php if ($notice !== '') : ?> <?php if ($notice !== '') : ?>
<p class="us-success"><?php echo esc_html($notice); ?></p> <p class="us-success"><?php echo esc_html($notice); ?></p>
@@ -27,7 +27,7 @@ if (! defined('ABSPATH')) {
<?php endif; ?> <?php endif; ?>
<?php if (empty($children)) : ?> <?php if (empty($children)) : ?>
<p><?php esc_html_e('You have not added any children yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<ul class="us-family-list"> <ul class="us-family-list">
<?php foreach ($children as $child) : ?> <?php foreach ($children as $child) : ?>
@@ -38,12 +38,12 @@ if (! defined('ABSPATH')) {
<input type="hidden" name="us_family_action" value="edit"> <input type="hidden" name="us_family_action" value="edit">
<input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>"> <input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>">
<p> <p>
<label for="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label> <label for="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required> <input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required>
</p> </p>
<p> <p>
<label for="us-edit-dob-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label> <label for="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="date" name="child_dob" id="us-edit-dob-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['date_of_birth']); ?>"> <input type="number" name="child_birth_year" required id="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<p> <p>
<button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button> <button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
@@ -52,8 +52,8 @@ if (! defined('ABSPATH')) {
</form> </form>
<?php else : ?> <?php else : ?>
<span class="us-family-child-name"><?php echo esc_html($child['name']); ?></span> <span class="us-family-child-name"><?php echo esc_html($child['name']); ?></span>
<?php if ($child['date_of_birth'] !== '') : ?> <?php if ($child['birth_year'] !== '') : ?>
<span class="us-family-child-dob"><?php echo esc_html($child['date_of_birth']); ?></span> <span class="us-family-child-birth-year"><?php echo esc_html($child['birth_year']); ?></span>
<?php endif; ?> <?php endif; ?>
<span class="us-family-child-actions"> <span class="us-family-child-actions">
<a href="<?php echo esc_url(add_query_arg('us_edit_child', $child['id'], (string) get_permalink())); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a> <a href="<?php echo esc_url(add_query_arg('us_edit_child', $child['id'], (string) get_permalink())); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
@@ -74,14 +74,14 @@ if (! defined('ABSPATH')) {
<?php wp_nonce_field('us_family'); ?> <?php wp_nonce_field('us_family'); ?>
<input type="hidden" name="us_family_action" value="add"> <input type="hidden" name="us_family_action" value="add">
<h4><?php esc_html_e('Add a child', 'unsupervised-schedular'); ?></h4> <h4><?php esc_html_e('Add a student', 'unsupervised-schedular'); ?></h4>
<p> <p>
<label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label> <label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="child_name" id="us-child-name" required> <input type="text" name="child_name" id="us-child-name" required>
</p> </p>
<p> <p>
<label for="us-child-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label> <label for="us-child-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="date" name="child_dob" id="us-child-dob"> <input type="number" name="child_birth_year" id="us-child-birth-year" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<p> <p>
<label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label> <label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label>
@@ -90,7 +90,7 @@ if (! defined('ABSPATH')) {
<?php if (! empty($questions)) : ?> <?php if (! empty($questions)) : ?>
<fieldset class="us-reg-questions"> <fieldset class="us-reg-questions">
<legend><?php esc_html_e('About this child', 'unsupervised-schedular'); ?></legend> <legend><?php esc_html_e('About this student', 'unsupervised-schedular'); ?></legend>
<?php foreach ($questions as $question) : ?> <?php foreach ($questions as $question) : ?>
<?php <?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
@@ -101,7 +101,7 @@ if (! defined('ABSPATH')) {
<?php endif; ?> <?php endif; ?>
<p> <p>
<button type="submit"><?php esc_html_e('Add child', 'unsupervised-schedular'); ?></button> <button type="submit"><?php esc_html_e('Add student', 'unsupervised-schedular'); ?></button>
</p> </p>
</form> </form>
</div> </div>
+18 -9
View File
@@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Registration\Question; use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionField; use Unsupervised\Schedular\Registration\QuestionField;
@@ -67,7 +68,15 @@ if (! defined('ABSPATH')) {
</p> </p>
<p> <p>
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label> <label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required> <input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="<?php echo esc_attr((string) PasswordPolicy::MIN_LENGTH); ?>" required aria-describedby="us-reg-pass-strength">
<?php
/*
* Filled in by register.js. `aria-live` announces the verdict as
* it changes, and it starts empty so nothing is announced or
* takes up space before anything has been typed.
*/
?>
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
</p> </p>
<fieldset class="us-guardian"> <fieldset class="us-guardian">
@@ -75,23 +84,23 @@ if (! defined('ABSPATH')) {
<p> <p>
<label> <label>
<input type="checkbox" name="us_is_guardian" id="us-is-guardian" value="1"> <input type="checkbox" name="us_is_guardian" id="us-is-guardian" value="1">
<?php esc_html_e("I'm registering as a parent or guardian, for one or more children", 'unsupervised-schedular'); ?> <?php esc_html_e("I'm registering as a parent or guardian, for one or more students", 'unsupervised-schedular'); ?>
</label> </label>
</p> </p>
<?php /* Revealed by the checkbox; without JS it is simply always visible. */ ?> <?php /* Revealed by the checkbox; without JS it is simply always visible. */ ?>
<div class="us-children" id="us-children"> <div class="us-children" id="us-children">
<p class="us-children-intro"><?php esc_html_e('Add each child you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p> <p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p>
<?php /* The first block is the template the "Add another child" button clones. */ ?> <?php /* The first block is the template the "Add another student" button clones. */ ?>
<div class="us-child" data-child-index="0"> <div class="us-child" data-child-index="0">
<p> <p>
<label for="us-child-0-name"><?php esc_html_e("Child's name", 'unsupervised-schedular'); ?></label> <label for="us-child-0-name"><?php esc_html_e("Student's name", 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="children[0][name]" id="us-child-0-name"> <input type="text" name="children[0][name]" id="us-child-0-name" aria-required="true" data-us-child-required>
</p> </p>
<p> <p>
<label for="us-child-0-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label> <label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="date" name="children[0][dob]" id="us-child-0-dob"> <input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" aria-required="true" data-us-child-required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<?php foreach ($accountQuestions as $question) : ?> <?php foreach ($accountQuestions as $question) : ?>
<?php <?php
@@ -107,7 +116,7 @@ if (! defined('ABSPATH')) {
</div> </div>
<p> <p>
<button type="button" class="us-add-child"><?php esc_html_e('Add another child', 'unsupervised-schedular'); ?></button> <button type="button" class="us-add-child"><?php esc_html_e('Add another student', 'unsupervised-schedular'); ?></button>
</p> </p>
</div> </div>
</fieldset> </fieldset>
+14
View File
@@ -0,0 +1,14 @@
# Writing tests
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies.
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
**Brain\Monkey API notes:**
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AccountPageTest extends TestCase
{
private AccountPage $page;
protected function setUp(): void
{
parent::setUp();
$this->page = new AccountPage();
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('get_permalink')->alias(
static fn (int $id = 0): string => $id > 0
? 'https://studio.test/sign-in/'
: 'https://studio.test/current/'
);
Functions\when('wp_logout_url')->alias(
static fn (string $redirect): string => 'https://studio.test/wp-login.php?action=logout&redirect_to=' . rawurlencode($redirect)
);
Functions\when('wp_get_current_user')->justReturn($this->user('Grace', 'Hopper', '[email protected]'));
}
private function user(string $first, string $last, string $email): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->ID = 5;
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = '';
$user->user_email = $email;
return $user;
}
public function testShowsTheSignedInNameAndEmail(): void
{
$html = $this->page->render([]);
self::assertStringContainsString('Grace Hopper', $html);
self::assertStringContainsString('[email protected]', $html);
self::assertStringContainsString('Sign out', $html);
}
public function testSigningOutReturnsToTheConfiguredLoginPage(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/sign-in/'),
$this->page->render(['loginPageId' => 9])
);
}
/**
* With no page chosen, signing out from a header link should leave the
* visitor where they were rather than navigating them somewhere.
*/
public function testSigningOutReturnsToTheCurrentPageWhenNoLoginPageIsSet(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/current/'),
$this->page->render([])
);
}
public function testTheShortcodeAttributeNameIsAccepted(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/sign-in/'),
$this->page->render(['login_page_id' => 9])
);
}
/**
* A block whose whole job is "you are signed in as X" has nothing to say to
* a stranger, and a bare notice in a site header cannot be acted on.
*/
public function testRendersNothingForASignedOutVisitorWithNoLoginPage(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
self::assertSame('', $this->page->render([]));
}
public function testOffersASignInLinkToASignedOutVisitorWhenAPageIsChosen(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
$html = $this->page->render(['loginPageId' => 9]);
self::assertStringContainsString('https://studio.test/sign-in/', $html);
self::assertStringContainsString('Sign in', $html);
self::assertStringNotContainsString('Sign out', $html);
}
/**
* A page can be deleted after it has been chosen in the block, which
* get_permalink() reports as false.
*/
public function testTreatsADeletedLoginPageAsNoneChosen(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->justReturn(false);
self::assertSame('', $this->page->render(['loginPageId' => 9]));
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PasswordPolicyTest extends TestCase
{
public function testAcceptsAnOrdinaryMemorablePassword(): void
{
self::assertNull(PasswordPolicy::validate('thistle-marrow-42', '[email protected]', 'Grace Hopper'));
}
/**
* A leading or trailing space is a character like any other. Trimming it
* would accept a password the user could then never type back.
*/
public function testCountsSurroundingSpaceAsPartOfThePassword(): void
{
self::assertNull(PasswordPolicy::validate(' spaced-out-phrase '));
// Seven characters counting both spaces: one short, and still one short
// after the spaces are counted rather than stripped.
self::assertNotNull(PasswordPolicy::validate(' short '));
}
/**
* @dataProvider tooShort
*/
public function testRejectsAPasswordShorterThanTheMinimum(string $password): void
{
self::assertStringContainsString('at least', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function tooShort(): array
{
return [
'empty' => [''],
'one short' => ['sevench'],
'a few chars' => ['abc'],
];
}
/**
* @dataProvider commonPasswords
*/
public function testRejectsAWellKnownPassword(string $password): void
{
self::assertStringContainsString('commonly used', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function commonPasswords(): array
{
return [
'password123' => ['password123'],
'shouting' => ['PASSWORD123'],
'mixed case' => ['PassWord123'],
'a keyboard walk' => ['qwertyuiop'],
'digits in a row' => ['123456789'],
'the classic' => ['iloveyou'],
];
}
/**
* @dataProvider tooFewDistinctCharacters
*/
public function testRejectsAPasswordBuiltFromAlmostNoDistinctCharacters(string $password): void
{
self::assertStringContainsString('repeated characters', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function tooFewDistinctCharacters(): array
{
return [
'one character' => ['aaaaaaaaaa'],
'two alternating' => ['abababababab'],
'three' => ['abcabcabcabc'],
];
}
/**
* @dataProvider identityEchoes
*/
public function testRejectsAPasswordContainingTheUsersOwnDetails(string $password, string $email, string $name): void
{
self::assertStringContainsString('name or email', (string) PasswordPolicy::validate($password, $email, $name));
}
/** @return array<string, array{string, string, string}> */
public static function identityEchoes(): array
{
return [
'the whole email' => ['[email protected]!', '[email protected]', 'Grace'],
'the local part' => ['grace-hopper-1906', '[email protected]', ''],
'the display name' => ['xxhopperxx-2019', '[email protected]', 'Hopper'],
'differing in case' => ['MyGRACEpassword', '[email protected]', ''],
];
}
/**
* A two- or three-letter overlap with a name is coincidence, not a weakness,
* and refusing it would be baffling to the person typing.
*/
public function testShortIdentityFragmentsDoNotTripTheCheck(): void
{
self::assertNull(PasswordPolicy::validate('bramble-thicket', '[email protected]', 'Bo'));
}
public function testAnEmptyIdentityIsNotTreatedAsContainedInEverything(): void
{
self::assertNull(PasswordPolicy::validate('bramble-thicket', '', ''));
}
}
+151 -30
View File
@@ -37,10 +37,18 @@ class RegistrationPageTest extends TestCase
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v); Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v); Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_email')->alias(static fn ($v) => $v); Functions\when('sanitize_email')->alias(static fn ($v) => $v);
// Reached on every submit now that the email is validated before the
// password, so the password can be checked against it.
Functions\when('is_email')->alias(static fn (string $v): bool => (bool) preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $v));
Functions\when('absint')->alias(static fn ($v) => (int) $v); Functions\when('absint')->alias(static fn ($v) => (int) $v);
Functions\when('current_time')->justReturn('2024-01-01 00:00:00'); // The birth-year check reads current_time('Y'), so answer that format
// properly rather than leaving it to cast out of the datetime string.
Functions\when('current_time')->alias(
static fn (string $type = 'mysql'): string => 'Y' === $type ? '2024' : '2024-01-01 00:00:00'
);
Functions\when('wp_enqueue_style')->justReturn(null); Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null); Functions\when('wp_enqueue_script')->justReturn(null);
Functions\when('wp_localize_script')->justReturn(true);
$invites = Mockery::mock(InviteRepository::class); $invites = Mockery::mock(InviteRepository::class);
$policies = Mockery::mock(PolicyRepository::class); $policies = Mockery::mock(PolicyRepository::class);
@@ -110,7 +118,7 @@ class RegistrationPageTest extends TestCase
public function testInviteBranchCreatesAndLogsInTheStudent(): void public function testInviteBranchCreatesAndLogsInTheStudent(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
@@ -127,7 +135,7 @@ class RegistrationPageTest extends TestCase
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
@@ -147,7 +155,7 @@ class RegistrationPageTest extends TestCase
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
@@ -174,7 +182,7 @@ class RegistrationPageTest extends TestCase
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
@@ -342,7 +350,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
@@ -361,7 +369,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
@@ -381,7 +389,7 @@ class RegistrationPageTest extends TestCase
public function testRecordsAccountAnswersOnSuccess(): void public function testRecordsAccountAnswersOnSuccess(): void
{ {
$_POST = [ $_POST = [
'password' => 'password123', 'password' => 'thistle-marrow-42',
'display_name' => 'Ada', 'display_name' => 'Ada',
'us_answers' => [ '5' => 'By a friend' ], 'us_answers' => [ '5' => 'By a friend' ],
]; ];
@@ -415,7 +423,7 @@ class RegistrationPageTest extends TestCase
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
{ {
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ]; $_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
$_REQUEST = [ 'us_invite' => 'raw-token' ]; $_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false); Functions\when('is_user_logged_in')->justReturn(false);
@@ -614,14 +622,14 @@ class RegistrationPageTest extends TestCase
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
{ {
$_POST = [ $_POST = [
'password' => 'password123', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']],
// An untouched spare block is dropped, not rejected. // An untouched spare block is dropped, not rejected.
['name' => ' ', 'dob' => '', 'answers' => []], ['name' => ' ', 'birth_year' => '', 'answers' => []],
], ],
]; ];
@@ -633,8 +641,8 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015-04-02')->andReturn(101); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')->andReturn(102);
$recorded = []; $recorded = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing( $this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
@@ -652,13 +660,64 @@ class RegistrationPageTest extends TestCase
self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded); self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
} }
/**
* The browser gates on zxcvbn, but that is advice a client can decline to
* take. Nothing is created for a password the server refuses.
*
* @dataProvider refusedPasswords
*/
public function testSignupRefusesAPasswordThePolicyRejects(string $password, string $expected): void
{
$_POST = [
'email' => '[email protected]',
'password' => $password,
'display_name' => 'Grace Hopper',
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString(
$expected,
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/** @return array<string, array{string, string}> */
public static function refusedPasswords(): array
{
return [
'too short' => ['abc123', 'at least'],
'a known password' => ['password123', 'commonly used'],
'barely any variety' => ['ababababab', 'repeated characters'],
'their own name' => ['grace-hopper-1906', 'name or email'],
];
}
public function testSignupRefusesAnAddressThatIsNotAnEmail(): void
{
$_POST = [
'email' => 'not-an-email',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString(
'valid email address',
$this->submit(null, true)
);
}
public function testGuardianSignupWithNoChildrenIsRejected(): void public function testGuardianSignupWithNoChildrenIsRejected(): void
{ {
$_POST = [ $_POST = [
'password' => 'password123', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [['name' => '', 'dob' => '', 'answers' => []]], 'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
]; ];
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
@@ -667,7 +726,69 @@ class RegistrationPageTest extends TestCase
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false); $result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
self::assertStringContainsString('at least one child', $result); self::assertStringContainsString('at least one student', $result);
}
/**
* A block the guardian actually typed into is theirs to correct, not ours to
* discard only a wholly untouched spare is dropped. Losing the birth year
* they filled in and registering a nameless student would be worse than
* telling them what is missing.
*/
public function testGuardianSignupRejectsAHalfFilledChildRatherThanDroppingIt(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => '', 'birth_year' => '2017', 'answers' => []],
],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString(
'give each student a name',
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/**
* @dataProvider rejectedBirthYears
*/
public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString(
'birth year',
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/** @return array<string, array{string}> */
public static function rejectedBirthYears(): array
{
return [
'left blank' => [''],
'a full date' => ['2015-04-02'],
'in the future' => ['2027'],
];
} }
/** /**
@@ -677,12 +798,12 @@ class RegistrationPageTest extends TestCase
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
{ {
$_POST = [ $_POST = [
'password' => 'password123', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']],
], ],
]; ];
@@ -694,7 +815,7 @@ class RegistrationPageTest extends TestCase
Functions\expect('wp_insert_user')->never(); Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild'); $this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString('for each child', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false)); self::assertStringContainsString('for each student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
} }
/** /**
@@ -704,12 +825,12 @@ class RegistrationPageTest extends TestCase
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
{ {
$_POST = [ $_POST = [
'password' => 'password123', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'dob' => '', 'answers' => []], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => 'Alan', 'dob' => '', 'answers' => []], ['name' => 'Alan', 'birth_year' => '2017', 'answers' => []],
], ],
]; ];
@@ -717,8 +838,8 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '')->andReturn(101); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '') $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')
->andReturn(new \WP_Error('link_failed', 'Nope.')); ->andReturn(new \WP_Error('link_failed', 'Nope.'));
$deleted = []; $deleted = [];
@@ -741,11 +862,11 @@ class RegistrationPageTest extends TestCase
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
{ {
$_POST = [ $_POST = [
'password' => 'password123', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'accept' => [3], 'accept' => [3],
'children' => [['name' => 'Ada', 'dob' => '', 'answers' => []]], 'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
]; ];
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3); $version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
@@ -780,7 +901,7 @@ class RegistrationPageTest extends TestCase
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
{ {
$_POST = ['password' => 'password123', 'display_name' => 'Ada']; $_POST = ['password' => 'thistle-marrow-42', 'display_name' => 'Ada'];
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
+6
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Actions; use Brain\Monkey\Actions;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\BlockRegistrar; use Unsupervised\Schedular\BlockRegistrar;
@@ -43,6 +44,7 @@ class BlockRegistrarTest extends TestCase
private RegistrationPage&Mockery\MockInterface $registrationPage; private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage; private GroupClassPage&Mockery\MockInterface $groupClassPage;
private FamilyPage&Mockery\MockInterface $familyPage; private FamilyPage&Mockery\MockInterface $familyPage;
private AccountPage&Mockery\MockInterface $accountPage;
private TestableBlockRegistrar $registrar; private TestableBlockRegistrar $registrar;
protected function setUp(): void protected function setUp(): void
@@ -54,6 +56,7 @@ class BlockRegistrarTest extends TestCase
$this->registrationPage = Mockery::mock(RegistrationPage::class); $this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class);
$this->familyPage = Mockery::mock(FamilyPage::class); $this->familyPage = Mockery::mock(FamilyPage::class);
$this->accountPage = Mockery::mock(AccountPage::class);
// Most requests are not a just-finished registration; the tests that // Most requests are not a just-finished registration; the tests that
// exercise that path override this. // exercise that path override this.
@@ -67,6 +70,7 @@ class BlockRegistrarTest extends TestCase
$this->registrationPage, $this->registrationPage,
$this->groupClassPage, $this->groupClassPage,
$this->familyPage, $this->familyPage,
$this->accountPage,
); );
} }
@@ -116,6 +120,7 @@ class BlockRegistrarTest extends TestCase
'us-scheduler/student-register', 'us-scheduler/student-register',
'us-scheduler/group-classes', 'us-scheduler/group-classes',
'us-scheduler/family', 'us-scheduler/family',
'us-scheduler/account',
], ],
array_keys($registered) array_keys($registered)
); );
@@ -213,6 +218,7 @@ class BlockRegistrarTest extends TestCase
$this->registrationPage, $this->registrationPage,
$this->groupClassPage, $this->groupClassPage,
$this->familyPage, $this->familyPage,
$this->accountPage,
); );
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live'); $this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
+13 -11
View File
@@ -38,6 +38,8 @@ class FamilyPageTest extends TestCase
Functions\when('wp_enqueue_style')->justReturn(null); Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_nonce_field')->justReturn(''); Functions\when('wp_nonce_field')->justReturn('');
Functions\when('get_permalink')->justReturn('https://studio.test/family/'); Functions\when('get_permalink')->justReturn('https://studio.test/family/');
// The birth-year input caps itself at the current year.
Functions\when('current_time')->justReturn('2026');
Functions\when('absint')->alias(static fn ($value) => abs((int) $value)); Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? '')); Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? ''));
Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v)); Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v));
@@ -86,21 +88,21 @@ class FamilyPageTest extends TestCase
$html = $this->page->render([]); $html = $this->page->render([]);
self::assertStringContainsString('log in to manage your family', $html); self::assertStringContainsString('log in to manage your profile', $html);
} }
public function testRenderListsTheGuardiansChildren(): void public function testRenderListsTheGuardiansChildren(): void
{ {
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([ $this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
['id' => 42, 'name' => 'Ada', 'date_of_birth' => '2015-04-02', 'relationship' => 'Parent'], ['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
]); ]);
$this->questions->shouldReceive('findByScope')->andReturn([]); $this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]); $html = $this->page->render([]);
self::assertStringContainsString('Ada', $html); self::assertStringContainsString('Ada', $html);
self::assertStringContainsString('2015-04-02', $html); self::assertStringContainsString('2015', $html);
self::assertStringContainsString('Add a child', $html); self::assertStringContainsString('Add a student', $html);
} }
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
@@ -108,13 +110,13 @@ class FamilyPageTest extends TestCase
$_POST = [ $_POST = [
'us_family_action' => 'add', 'us_family_action' => 'add',
'child_name' => 'Ada', 'child_name' => 'Ada',
'child_dob' => '2015-04-02', 'child_birth_year' => '2015',
'child_relationship' => 'Parent', 'child_relationship' => 'Parent',
'us_answers' => [7 => 'Piano'], 'us_answers' => [7 => 'Piano'],
]; ];
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]); $this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015-04-02', 'Parent')->andReturn(42); $this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015', 'Parent')->andReturn(42);
$this->answers->shouldReceive('insert') $this->answers->shouldReceive('insert')
->once() ->once()
@@ -161,7 +163,7 @@ class FamilyPageTest extends TestCase
$_POST = ['us_family_action' => 'add', 'child_name' => '']; $_POST = ['us_family_action' => 'add', 'child_name' => ''];
$this->questions->shouldReceive('findByScope')->once()->andReturn([]); $this->questions->shouldReceive('findByScope')->once()->andReturn([]);
$this->guardians->shouldReceive('createChild')->once()->andReturn(new \WP_Error('missing_name', 'Please give each child a name.')); $this->guardians->shouldReceive('createChild')->once()->andReturn(new \WP_Error('missing_name', 'Please give each student a name.'));
$this->answers->shouldNotReceive('insert'); $this->answers->shouldNotReceive('insert');
$captured = null; $captured = null;
@@ -179,10 +181,10 @@ class FamilyPageTest extends TestCase
'us_family_action' => 'edit', 'us_family_action' => 'edit',
'child_id' => '42', 'child_id' => '42',
'child_name' => 'Ada L', 'child_name' => 'Ada L',
'child_dob' => '2015-04-02', 'child_birth_year' => '2015',
]; ];
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(null); $this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015')->andReturn(null);
$captured = null; $captured = null;
$this->capturingPage($captured)->maybeHandleSubmit(); $this->capturingPage($captured)->maybeHandleSubmit();
@@ -207,7 +209,7 @@ class FamilyPageTest extends TestCase
$_POST = ['us_family_action' => 'remove', 'child_id' => '42']; $_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldReceive('removeChild')->once()->andReturn( $this->guardians->shouldReceive('removeChild')->once()->andReturn(
new \WP_Error('has_history', 'This child has lessons or enrolments on record.') new \WP_Error('has_history', 'This student has lessons or enrolments on record.')
); );
$captured = null; $captured = null;
@@ -271,6 +273,6 @@ class FamilyPageTest extends TestCase
$this->guardians->shouldReceive('children')->andReturn([]); $this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]); $this->questions->shouldReceive('findByScope')->andReturn([]);
self::assertStringContainsString('Child added.', $this->page->render([])); self::assertStringContainsString('Student added.', $this->page->render([]));
} }
} }
+92 -11
View File
@@ -52,6 +52,8 @@ class GuardianServiceTest extends TestCase
return true; return true;
} }
); );
// The birth-year range is validated against "this year", so pin it.
Functions\when('current_time')->justReturn('2026');
Functions\when('wp_generate_password')->justReturn('abc123def456'); Functions\when('wp_generate_password')->justReturn('abc123def456');
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
@@ -84,14 +86,14 @@ class GuardianServiceTest extends TestCase
->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent')) ->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent'))
->andReturn(7); ->andReturn(7);
$result = $this->service->createChild(5, ' Ada ', '2015-04-02', 'Parent'); $result = $this->service->createChild(5, ' Ada ', '2015', 'Parent');
self::assertSame(42, $result); self::assertSame(42, $result);
self::assertSame('Ada', $captured['display_name']); self::assertSame('Ada', $captured['display_name']);
// The address is on the reserved .invalid TLD, so it can never receive mail. // The address is on the reserved .invalid TLD, so it can never receive mail.
self::assertStringEndsWith('@child.invalid', $captured['user_email']); self::assertStringEndsWith('@child.invalid', $captured['user_email']);
self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]); self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]);
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
} }
public function testCreateChildRejectsABlankName(): void public function testCreateChildRejectsABlankName(): void
@@ -114,17 +116,49 @@ class GuardianServiceTest extends TestCase
Functions\expect('wp_delete_user')->once()->with(42); Functions\expect('wp_delete_user')->once()->with(42);
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada')); self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada', '2015'));
} }
public function testCreateChildClearsAnUnparseableDateOfBirth(): void /**
* @dataProvider unusableBirthYears
*/
public function testCreateChildRefusesAnUnusableBirthYear(string $submitted): void
{ {
Functions\when('wp_insert_user')->justReturn(42); // Refused before anything is written, so no orphan user is left behind.
$this->guardians->shouldReceive('insert')->once()->andReturn(7); Functions\expect('wp_insert_user')->never();
$this->guardians->shouldNotReceive('insert');
$this->service->createChild(5, 'Ada', 'not-a-date'); $result = $this->service->createChild(5, 'Ada', $submitted);
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []); self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('missing_birth_year', $result->get_error_code());
self::assertArrayNotHasKey(42, $this->meta);
}
/** @dataProvider unusableBirthYears */
public function testUpdateChildRefusesAnUnusableBirthYear(string $submitted): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\expect('wp_update_user')->never();
$result = $this->service->updateChild(5, 42, 'Ada', $submitted);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('missing_birth_year', $result->get_error_code());
}
/** @return array<string, array{string}> */
public static function unusableBirthYears(): array
{
return [
'not a number' => ['not-a-year'],
'a full date' => ['2015-04-02'],
'too few digits' => ['15'],
'too many digits' => ['20155'],
'before 1900' => ['1899'],
'later than today' => ['2027'],
'left blank' => [''],
];
} }
public function testCanActForSelfAndOwnChildOnly(): void public function testCanActForSelfAndOwnChildOnly(): void
@@ -187,6 +221,53 @@ class GuardianServiceTest extends TestCase
self::assertSame([false, false, true], array_column($students, 'is_self')); self::assertSame([false, false, true], array_column($students, 'is_self'));
} }
public function testChildrenReportsTheStoredBirthYear(): void
{
$this->meta[42][GuardianService::META_BIRTH_YEAR] = '2015';
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
}
/**
* A child added before this feature switched to a year has only the old full
* date on record, and must still show a birth year.
*/
public function testChildrenDerivesABirthYearFromALegacyDateOfBirth(): void
{
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
}
/**
* Saving a child drops the legacy full date, so the fallback above can never
* outrank a year the guardian has since corrected by hand.
*/
public function testSavingAChildClearsTheLegacyDateOfBirth(): void
{
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\when('wp_update_user')->justReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2016'));
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
self::assertSame('2016', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
// The corrected year is what is read back, not the year of the old date.
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2016', $this->service->children(5)[0]['birth_year']);
}
public function testBookableStudentsIsJustTheUserWithoutChildren(): void public function testBookableStudentsIsJustTheUserWithoutChildren(): void
{ {
$this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]); $this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]);
@@ -228,7 +309,7 @@ class GuardianServiceTest extends TestCase
self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory')); self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory'));
} }
public function testUpdateChildRenamesAndStoresTheDateOfBirth(): void public function testUpdateChildRenamesAndStoresTheBirthYear(): void
{ {
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\expect('wp_update_user') Functions\expect('wp_update_user')
@@ -236,8 +317,8 @@ class GuardianServiceTest extends TestCase
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L']) ->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
->andReturn(42); ->andReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015-04-02')); self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015'));
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
} }
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
+5 -1
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Actions; use Brain\Monkey\Actions;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Booking\BookingPage;
@@ -20,6 +21,7 @@ class ShortcodeRegistrarTest extends TestCase
private RegistrationPage&Mockery\MockInterface $registrationPage; private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage; private GroupClassPage&Mockery\MockInterface $groupClassPage;
private FamilyPage&Mockery\MockInterface $familyPage; private FamilyPage&Mockery\MockInterface $familyPage;
private AccountPage&Mockery\MockInterface $accountPage;
private ShortcodeRegistrar $registrar; private ShortcodeRegistrar $registrar;
/** @var array<string, callable> */ /** @var array<string, callable> */
@@ -37,6 +39,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrationPage = Mockery::mock(RegistrationPage::class); $this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class);
$this->familyPage = Mockery::mock(FamilyPage::class); $this->familyPage = Mockery::mock(FamilyPage::class);
$this->accountPage = Mockery::mock(AccountPage::class);
$this->registrar = new ShortcodeRegistrar( $this->registrar = new ShortcodeRegistrar(
$this->bookingPage, $this->bookingPage,
@@ -44,6 +47,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrationPage, $this->registrationPage,
$this->groupClassPage, $this->groupClassPage,
$this->familyPage, $this->familyPage,
$this->accountPage,
); );
$shortcodes = &$this->shortcodes; $shortcodes = &$this->shortcodes;
@@ -66,7 +70,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrar->register(); $this->registrar->register();
self::assertSame( self::assertSame(
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family'], ['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family', 'us_account'],
array_keys($this->shortcodes) array_keys($this->shortcodes)
); );
} }
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.3.0 * Version: 1.3.1
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.3.0'); define('USC_VERSION', '1.3.1');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));